Like [`execute_steps_parallel`](super::execute_steps_parallel), but resumable**: progress is journaled to `store` under `workflow_id`, so an interrupted run picks up from the last completed step. On entry, any steps already recorded in a prior checkpoint are skipped and their cached outcomes reused; only the rest are dispatched. As each step completes, the checkpoint is rewritten (the step bounda
(
executor: Arc<dyn AgentExecutor>,
specs: Vec<AgentStepSpec>,
workflow_id: &str,
store: Arc<dyn SessionStore>,
event_tx: Option<broadcast::Sender<AgentEvent>>,
)
| 100 | /// checkpoint is deleted (the workflow is terminal); only a crash leaves one |
| 101 | /// behind for resume. |
| 102 | pub async fn execute_steps_parallel_resumable( |
| 103 | executor: Arc<dyn AgentExecutor>, |
| 104 | specs: Vec<AgentStepSpec>, |
| 105 | workflow_id: &str, |
| 106 | store: Arc<dyn SessionStore>, |
| 107 | event_tx: Option<broadcast::Sender<AgentEvent>>, |
| 108 | ) -> Vec<StepOutcome> { |
| 109 | // Prior progress. An unreadable checkpoint — e.g. one written by a newer, |
| 110 | // incompatible schema version, which the store rejects via |
| 111 | // `ensure_loadable` — is treated as *no* prior progress: the workflow |
| 112 | // re-runs from scratch rather than resuming from state it can't interpret. |
| 113 | // That's a fail-safe (do the work), but surface it rather than swallow it. |
| 114 | let done: HashMap<String, StepOutcome> = match store.load_workflow_checkpoint(workflow_id).await |
| 115 | { |
| 116 | Ok(Some(cp)) => cp.completed(), |
| 117 | Ok(None) => HashMap::new(), |
| 118 | Err(e) => { |
| 119 | tracing::warn!( |
| 120 | workflow_id = %workflow_id, |
| 121 | error = %e, |
| 122 | "workflow checkpoint unreadable; re-running the workflow from scratch" |
| 123 | ); |
| 124 | HashMap::new() |
| 125 | } |
| 126 | }; |
| 127 | |
| 128 | let pending: Vec<AgentStepSpec> = specs |
| 129 | .iter() |
| 130 | .filter(|s| !done.contains_key(&s.task_id)) |
| 131 | .cloned() |
| 132 | .collect(); |
| 133 | let labels: Vec<(String, String)> = pending |
| 134 | .iter() |
| 135 | .map(|s| (s.task_id.clone(), s.agent.clone())) |
| 136 | .collect(); |
| 137 | |
| 138 | // Accumulator seeded with prior progress; persisted at every step boundary. |
| 139 | let acc = Arc::new(tokio::sync::Mutex::new(done.clone())); |
| 140 | let limit = executor.concurrency_hint(); |
| 141 | let workflow_id_owned = workflow_id.to_string(); |
| 142 | let store_steps = Arc::clone(&store); |
| 143 | |
| 144 | let results = run_ordered_parallel_with_limit(pending, limit, move |_idx, spec| { |
| 145 | let executor = Arc::clone(&executor); |
| 146 | let event_tx = event_tx.clone(); |
| 147 | let acc = Arc::clone(&acc); |
| 148 | let store = Arc::clone(&store_steps); |
| 149 | let workflow_id = workflow_id_owned.clone(); |
| 150 | async move { |
| 151 | let outcome = executor.execute_step(spec, event_tx).await; |
| 152 | // Step boundary: record only *successful* steps, so a failed step |
| 153 | // is retried on resume (its effect didn't complete) while a |
| 154 | // succeeded step's work is never redone. |
| 155 | if outcome.success { |
| 156 | let mut guard = acc.lock().await; |
| 157 | guard.insert(outcome.task_id.clone(), outcome.clone()); |
| 158 | let checkpoint = |
| 159 | WorkflowCheckpoint::from_completed(&workflow_id, &guard, now_epoch_ms()); |