Skip to content

Commit b357236

Browse files
committed
refactor(workflow): stage runtime input readiness advancement
Add a workflow-service scheduler orchestrator path that advances dependent runtime inference tasks from AwaitingInputs to WaitingDependencyReadiness only after connected upstream task results materialize. Keep runtime dispatch disabled in this slice: no Ready detour, no handoff synthesis, no graph paths, and no ModelRefV2 or ModelDependencyRequest adaptation. Tests cover materialized and blocked runtime inputs, and the plan records the dedicated session/runtime runner follow-up.
1 parent 7d2be99 commit b357236

4 files changed

Lines changed: 332 additions & 0 deletions

File tree

crates/pantograph-workflow-service/src/scheduler/task_orchestrator.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,111 @@ impl WorkflowSchedulerTaskOrchestrator {
423423
}
424424
}
425425

426+
#[allow(dead_code)]
427+
pub(crate) fn advance_awaiting_runtime_task_inputs(
428+
&self,
429+
store: &mut WorkflowExecutionSessionStore,
430+
session_id: &str,
431+
workflow_run_id: &str,
432+
task_id: &str,
433+
) -> Result<Option<SchedulerTaskStateRecord>, WorkflowSchedulerTaskOrchestratorError> {
434+
let (task_graph, records) = store
435+
.active_run_scheduler_task_state(session_id, workflow_run_id)
436+
.map_err(WorkflowSchedulerTaskOrchestratorError::WorkflowService)?
437+
.ok_or_else(|| {
438+
WorkflowSchedulerTaskOrchestratorError::WorkflowService(
439+
WorkflowServiceError::InvalidRequest(format!(
440+
"active workflow run '{}' has no scheduler task graph",
441+
workflow_run_id
442+
)),
443+
)
444+
})?;
445+
let task = task_graph
446+
.tasks
447+
.iter()
448+
.find(|task| task.task_id.as_str() == task_id)
449+
.ok_or_else(|| {
450+
WorkflowSchedulerTaskOrchestratorError::WorkflowService(
451+
WorkflowServiceError::InvalidRequest(format!(
452+
"scheduler task '{}' is not in active workflow run '{}'",
453+
task_id, workflow_run_id
454+
)),
455+
)
456+
})?;
457+
if task.execution_class != WorkflowSchedulerTaskExecutionClass::RuntimeInference {
458+
return Err(WorkflowSchedulerTaskOrchestratorError::WorkflowService(
459+
WorkflowServiceError::InvalidRequest(format!(
460+
"scheduler task '{}' is not a runtime inference task",
461+
task_id
462+
)),
463+
));
464+
}
465+
let record = records
466+
.iter()
467+
.find(|record| record.task_id.as_str() == task_id)
468+
.ok_or_else(|| {
469+
WorkflowSchedulerTaskOrchestratorError::WorkflowService(
470+
WorkflowServiceError::InvalidRequest(format!(
471+
"scheduler task '{}' has no active task-state record",
472+
task_id
473+
)),
474+
)
475+
})?;
476+
if record.state.kind() != SchedulerTaskStateKind::AwaitingInputs {
477+
return Err(WorkflowSchedulerTaskOrchestratorError::WorkflowService(
478+
WorkflowServiceError::InvalidRequest(format!(
479+
"scheduler task '{}' must be awaiting inputs before runtime readiness advancement",
480+
task_id
481+
)),
482+
));
483+
}
484+
485+
let results = store
486+
.active_run_scheduler_task_results(session_id, workflow_run_id)
487+
.map_err(WorkflowSchedulerTaskOrchestratorError::WorkflowService)?;
488+
match runtime_input_readiness(task, &results) {
489+
RuntimeInputReadiness::Blocked => Ok(None),
490+
RuntimeInputReadiness::Ready => {
491+
let transition =
492+
waiting_dependency_readiness_transition_from_awaiting_inputs(task, record)?;
493+
store
494+
.apply_active_run_scheduler_task_transition(
495+
session_id,
496+
workflow_run_id,
497+
transition,
498+
)
499+
.map_err(WorkflowSchedulerTaskOrchestratorError::WorkflowService)
500+
.and_then(applied_task_state_record)
501+
.map(Some)
502+
}
503+
RuntimeInputReadiness::InputUnavailable(diagnostic) => {
504+
let transition =
505+
input_unavailable_transition_from_awaiting_inputs(record, diagnostic)?;
506+
store
507+
.apply_active_run_scheduler_task_transition(
508+
session_id,
509+
workflow_run_id,
510+
transition,
511+
)
512+
.map_err(WorkflowSchedulerTaskOrchestratorError::WorkflowService)
513+
.and_then(applied_task_state_record)
514+
.map(Some)
515+
}
516+
RuntimeInputReadiness::Invalid(diagnostic) => {
517+
let transition = invalid_transition_from_awaiting_inputs(record, diagnostic)?;
518+
store
519+
.apply_active_run_scheduler_task_transition(
520+
session_id,
521+
workflow_run_id,
522+
transition,
523+
)
524+
.map_err(WorkflowSchedulerTaskOrchestratorError::WorkflowService)
525+
.and_then(applied_task_state_record)
526+
.map(Some)
527+
}
528+
}
529+
}
530+
426531
#[allow(dead_code)]
427532
pub(crate) fn apply_runtime_dependency_readiness_admission(
428533
&self,
@@ -941,6 +1046,21 @@ fn ready_transition_from_awaiting_inputs(
9411046
)
9421047
}
9431048

1049+
#[allow(dead_code)]
1050+
fn waiting_dependency_readiness_transition_from_awaiting_inputs(
1051+
task: &WorkflowSchedulerTask,
1052+
record: &SchedulerTaskStateRecord,
1053+
) -> Result<SchedulerTaskStateTransition, WorkflowSchedulerTaskOrchestratorError> {
1054+
task_state_transition(
1055+
record,
1056+
"runtime-inputs-ready",
1057+
SchedulerTaskStateKind::AwaitingInputs,
1058+
SchedulerTaskState::WaitingDependencyReadiness {
1059+
execution_intent: runtime_execution_intent(task)?,
1060+
},
1061+
)
1062+
}
1063+
9441064
fn input_unavailable_transition_from_awaiting_inputs(
9451065
record: &SchedulerTaskStateRecord,
9461066
diagnostic: SchedulerTaskStateDiagnostic,
@@ -1163,6 +1283,40 @@ enum NonRuntimeInputReadiness {
11631283
Invalid(SchedulerTaskStateDiagnostic),
11641284
}
11651285

1286+
#[allow(dead_code)]
1287+
enum RuntimeInputReadiness {
1288+
Ready,
1289+
Blocked,
1290+
InputUnavailable(SchedulerTaskStateDiagnostic),
1291+
Invalid(SchedulerTaskStateDiagnostic),
1292+
}
1293+
1294+
#[allow(dead_code)]
1295+
fn runtime_input_readiness(
1296+
task: &WorkflowSchedulerTask,
1297+
results: &[WorkflowSchedulerTaskResult],
1298+
) -> RuntimeInputReadiness {
1299+
if task.schedulable_intent.is_none() {
1300+
return RuntimeInputReadiness::Invalid(scheduler_input_diagnostic(
1301+
SchedulerTaskStateDiagnosticCode::InvalidTask,
1302+
"runtime scheduler task is missing a typed runtime execution intent",
1303+
));
1304+
}
1305+
for binding in &task.input_bindings {
1306+
match materialized_bound_output(task, results, binding) {
1307+
MaterializedBindingValue::Ready(_) => {}
1308+
MaterializedBindingValue::Blocked => return RuntimeInputReadiness::Blocked,
1309+
MaterializedBindingValue::Unavailable(diagnostic) => {
1310+
return RuntimeInputReadiness::InputUnavailable(diagnostic);
1311+
}
1312+
MaterializedBindingValue::Invalid(diagnostic) => {
1313+
return RuntimeInputReadiness::Invalid(diagnostic);
1314+
}
1315+
}
1316+
}
1317+
RuntimeInputReadiness::Ready
1318+
}
1319+
11661320
fn non_runtime_input_readiness(
11671321
task: &WorkflowSchedulerTask,
11681322
results: &[WorkflowSchedulerTaskResult],
@@ -1198,6 +1352,21 @@ fn non_runtime_input_readiness(
11981352
}
11991353
}
12001354

1355+
#[allow(dead_code)]
1356+
fn runtime_execution_intent(
1357+
task: &WorkflowSchedulerTask,
1358+
) -> Result<SchedulerTaskExecutionIntent, WorkflowSchedulerTaskOrchestratorError> {
1359+
let Some(task_intent) = task.schedulable_intent.clone() else {
1360+
return Err(WorkflowSchedulerTaskOrchestratorError::WorkflowService(
1361+
WorkflowServiceError::InvalidRequest(format!(
1362+
"runtime scheduler task '{}' is missing a typed runtime execution intent",
1363+
task.task_id.as_str()
1364+
)),
1365+
));
1366+
};
1367+
Ok(SchedulerTaskExecutionIntent::Runtime { task_intent })
1368+
}
1369+
12011370
enum MaterializedBindingValue<'a> {
12021371
Ready(&'a WorkflowSchedulerTaskResultValue),
12031372
Blocked,

crates/pantograph-workflow-service/src/scheduler/task_orchestrator_tests.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,84 @@ fn orchestrator_initializes_dependent_runtime_task_as_awaiting_inputs() {
399399
);
400400
}
401401

402+
#[test]
403+
fn orchestrator_advances_dependent_runtime_task_when_inputs_materialize() {
404+
let orchestrator = orchestrator_without_runtime_host_response();
405+
let task_intent = runtime_host_request_fixture().handoff.task_intent;
406+
let task_id = task_intent.task_id.as_str().to_string();
407+
let mut task = task_from_intent(task_intent.clone());
408+
task.dependency_task_ids = vec![SchedulerTaskId::parse("prompt").expect("task id")];
409+
task.input_bindings = vec![text_binding("prompt", task.task_id.as_str())];
410+
let source = text_input_task_for_runtime_intent(&task_intent, "prompt");
411+
let task_graph = task_graph(vec![source, task]);
412+
let workflow_run_id = task_graph.workflow_run_id.as_str().to_string();
413+
let mut store = WorkflowExecutionSessionStore::new(1, 1);
414+
let session_id = begin_active_run_for_task_graph(&mut store, &task_graph);
415+
orchestrator
416+
.initialize_active_run_task_state(
417+
&mut store,
418+
&session_id,
419+
&workflow_run_id,
420+
task_graph.clone(),
421+
)
422+
.expect("initialize active run task state");
423+
store
424+
.record_active_run_scheduler_task_result(
425+
&session_id,
426+
&workflow_run_id,
427+
text_result_for_runtime_intent(
428+
&task_intent,
429+
"prompt",
430+
WorkflowSchedulerTaskResultStatus::Completed,
431+
),
432+
)
433+
.expect("record prompt result");
434+
435+
let advanced = orchestrator
436+
.advance_awaiting_runtime_task_inputs(&mut store, &session_id, &workflow_run_id, &task_id)
437+
.expect("advance runtime task")
438+
.expect("runtime task should advance");
439+
440+
assert_eq!(advanced.state_version, 2);
441+
let SchedulerTaskState::WaitingDependencyReadiness { execution_intent } = advanced.state else {
442+
panic!("expected waiting dependency readiness");
443+
};
444+
assert!(execution_intent.runtime_task_intent().is_some());
445+
}
446+
447+
#[test]
448+
fn orchestrator_leaves_dependent_runtime_task_blocked_without_materialized_input() {
449+
let orchestrator = orchestrator_without_runtime_host_response();
450+
let task_intent = runtime_host_request_fixture().handoff.task_intent;
451+
let task_id = task_intent.task_id.as_str().to_string();
452+
let mut task = task_from_intent(task_intent.clone());
453+
task.dependency_task_ids = vec![SchedulerTaskId::parse("prompt").expect("task id")];
454+
task.input_bindings = vec![text_binding("prompt", task.task_id.as_str())];
455+
let source = text_input_task_for_runtime_intent(&task_intent, "prompt");
456+
let task_graph = task_graph(vec![source, task]);
457+
let workflow_run_id = task_graph.workflow_run_id.as_str().to_string();
458+
let mut store = WorkflowExecutionSessionStore::new(1, 1);
459+
let session_id = begin_active_run_for_task_graph(&mut store, &task_graph);
460+
orchestrator
461+
.initialize_active_run_task_state(&mut store, &session_id, &workflow_run_id, task_graph)
462+
.expect("initialize active run task state");
463+
464+
let advanced = orchestrator
465+
.advance_awaiting_runtime_task_inputs(&mut store, &session_id, &workflow_run_id, &task_id)
466+
.expect("advance runtime task");
467+
468+
assert!(advanced.is_none());
469+
let (_stored_graph, records) = store
470+
.active_run_scheduler_task_state(&session_id, &workflow_run_id)
471+
.expect("active run task state")
472+
.expect("stored task state");
473+
let record = records
474+
.iter()
475+
.find(|record| record.task_id.as_str() == task_id)
476+
.expect("runtime task record");
477+
assert_eq!(record.state.kind(), SchedulerTaskStateKind::AwaitingInputs);
478+
}
479+
402480
#[test]
403481
fn orchestrator_initializes_awaiting_inputs_for_pre_intent_task() {
404482
let orchestrator = orchestrator_without_runtime_host_response();
@@ -1328,6 +1406,16 @@ fn text_input_task(task_id: &str, _value: &str) -> WorkflowSchedulerTask {
13281406
}
13291407
}
13301408

1409+
fn text_input_task_for_runtime_intent(
1410+
task_intent: &SchedulableTaskIntent,
1411+
task_id: &str,
1412+
) -> WorkflowSchedulerTask {
1413+
let mut task = text_input_task(task_id, "paint a red cube");
1414+
task.workflow_id = task_intent.workflow_id.clone();
1415+
task.workflow_run_id = task_intent.workflow_run_id.clone();
1416+
task
1417+
}
1418+
13311419
fn text_output_task() -> WorkflowSchedulerTask {
13321420
WorkflowSchedulerTask {
13331421
workflow_id: scheduler_workflow_id(),
@@ -1392,6 +1480,17 @@ fn text_result(
13921480
)
13931481
}
13941482

1483+
fn text_result_for_runtime_intent(
1484+
task_intent: &SchedulableTaskIntent,
1485+
task_id: &str,
1486+
status: WorkflowSchedulerTaskResultStatus,
1487+
) -> WorkflowSchedulerTaskResult {
1488+
let mut result = text_result(task_id, status);
1489+
result.workflow_id = task_intent.workflow_id.as_str().to_string();
1490+
result.workflow_run_id = task_intent.workflow_run_id.as_str().to_string();
1491+
result
1492+
}
1493+
13951494
fn bool_result(
13961495
task_id: &str,
13971496
status: WorkflowSchedulerTaskResultStatus,

docs/plans/current-image-generation-graphs/05-execution-management.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16406,6 +16406,35 @@ Worker rules:
1640616406
fixture value.
1640716407
- Remaining follow-up: add workflow-service runtime input advancement using
1640816408
this scheduler-owned transition contract.
16409+
- 2026-05-29 Milestone 5b workflow-service runtime input advancement slice
16410+
completed:
16411+
- Slice scope: workflow-service scheduler task orchestrator/tests plus plan
16412+
records.
16413+
- Implementation: added an orchestrator path that advances dependent runtime
16414+
inference tasks from `AwaitingInputs` to `WaitingDependencyReadiness` only
16415+
when connected upstream scheduler task results have materialized. Missing
16416+
inputs leave the task blocked in `AwaitingInputs`; unavailable or invalid
16417+
materialized inputs produce typed scheduler diagnostics.
16418+
- No-fallback/no-legacy gate: this does not dispatch runtime work, does not
16419+
route runtime inference through `Ready`, does not synthesize handoff, does
16420+
not read graph paths or executable load targets, and does not adapt
16421+
readiness into `ModelRefV2` or `ModelDependencyRequest`.
16422+
- Verification passed: `cargo fmt -p pantograph-workflow-service`; `cargo
16423+
test -p pantograph-workflow-service task_orchestrator --lib --
16424+
--nocapture`; `cargo check -p pantograph-workflow-service`; `cargo fmt -p
16425+
pantograph-workflow-service -- --check`; `cargo check -p
16426+
pantograph-workflow-service --all-features`; `cargo check -p
16427+
pantograph-workflow-service --no-default-features`; targeted retired
16428+
path/model-ref source search over touched workflow-service scheduler files
16429+
and the session API; and `git diff --check`.
16430+
- Verification caveat: `cargo check -p pantograph-workflow-service` still
16431+
emits the known unused `set_active_run_execution_plan` warning.
16432+
- Deviation recorded: session-runner wiring was attempted and reverted
16433+
because it widened existing session behavior before the dedicated runtime
16434+
runner boundary was planned and tested.
16435+
- Remaining follow-up: implement the dedicated session/runtime runner slice
16436+
so upstream result recording invokes this advancement path without
16437+
reviving old planned-inference launch behavior.
1640916438

1641016439
### Traceability Links
1641116440

docs/plans/current-image-generation-graphs/milestones/05b-runtime-host-handoff-legacy-removal.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ this transition only in workflow-service; the legal lifecycle belongs in the
8282
must not be used to launch inference or build handoff. Runtime requests must
8383
include the canonical dependency readiness proof and workflow-service-owned
8484
materialized runtime inputs derived from validated upstream task results.
85+
- [ ] Wire the session/runtime runner to call workflow-service runtime input
86+
advancement after upstream task results are recorded. This must be a
87+
dedicated runner slice, because direct wiring through the existing
88+
fail-closed runtime session branch changes legacy/session expectations and
89+
risks reintroducing broad compatibility behavior. The runner must keep graph
90+
editing, validation, dependency readiness, runtime input materialization, and
91+
runtime-host dispatch as separate boundaries.
8592
- [ ] Retire node-engine planned-inference launch ownership for runtime
8693
inference nodes. Affected nodes must submit or reference scheduler task
8794
intent and consume scheduler task state/results; missing scheduler task state
@@ -363,3 +370,31 @@ this transition only in workflow-service; the legal lifecycle belongs in the
363370
path-shaped-field rejection test and the existing typed Pumas fixture value.
364371
Remaining follow-up: retry workflow-service runtime input advancement on top
365372
of the scheduler-owned transition contract.
373+
- 2026-05-29 workflow-service runtime input advancement slice completed.
374+
Smallest useful vertical slice: add a workflow-service orchestrator method
375+
that advances a dependent runtime inference task from `AwaitingInputs` to
376+
`WaitingDependencyReadiness` only after all connected upstream scheduler task
377+
results have materialized. Allowed write set:
378+
`crates/pantograph-workflow-service/src/scheduler/task_orchestrator.rs`,
379+
`crates/pantograph-workflow-service/src/scheduler/task_orchestrator_tests.rs`,
380+
this milestone file, and execution notes. No-fallback confirmation: the
381+
slice does not dispatch runtime work, does not route runtime tasks through
382+
`Ready`, does not synthesize handoff, does not read graph paths or executable
383+
load targets, and does not adapt runtime readiness into `ModelRefV2` or
384+
`ModelDependencyRequest`. Blocked inputs return no transition; unavailable
385+
or invalid materialized inputs move through typed scheduler diagnostics.
386+
Verification passed: `cargo fmt -p pantograph-workflow-service`; `cargo
387+
test -p pantograph-workflow-service task_orchestrator --lib --
388+
--nocapture`; `cargo check -p pantograph-workflow-service`; `cargo fmt -p
389+
pantograph-workflow-service -- --check`; `cargo check -p
390+
pantograph-workflow-service --all-features`; `cargo check -p
391+
pantograph-workflow-service --no-default-features`; targeted retired
392+
path/model-ref source search over touched workflow-service scheduler files
393+
and the session API; and `git diff --check`. Verification caveat: `cargo
394+
check -p pantograph-workflow-service` still emits the known unused
395+
`set_active_run_execution_plan` warning. Deviation recorded: session-runner
396+
wiring was attempted and reverted because it widened existing session
397+
behavior before the dedicated runtime runner boundary was planned and tested.
398+
Remaining follow-up: implement the dedicated session/runtime runner slice so
399+
upstream result recording invokes this advancement path without reviving old
400+
planned-inference launch behavior.

0 commit comments

Comments
 (0)