Skip to main content

a3s_code_core/orchestration/
combinators.rs

1//! Orchestration combinators built on the [`AgentExecutor`] seam.
2//!
3//! [`execute_steps_parallel`](super::execute_steps_parallel) (in `executor`)
4//! is the barrier (`parallel`) primitive. This module adds `pipeline`: the
5//! one genuinely new scheduling shape, where each item flows through a chain
6//! of stages independently — no barrier between stages.
7
8use super::checkpoint::{workflow_step_result_receipt, WorkflowCheckpoint};
9use super::executor::{execute_steps_parallel, AgentExecutor, AgentStepSpec, StepOutcome};
10use crate::agent::AgentEvent;
11use crate::execution_identity::ExecutionResultReceiptV1;
12use crate::ordered_parallel::run_ordered_parallel_with_limit;
13use crate::store::SessionStore;
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::sync::broadcast;
17
18fn now_epoch_ms() -> u64 {
19    std::time::SystemTime::now()
20        .duration_since(std::time::UNIX_EPOCH)
21        .map(|d| d.as_millis() as u64)
22        .unwrap_or(0)
23}
24
25/// A pipeline stage: given the previous stage's outcome (`None` before the
26/// first stage) and the original item, produce the next step to run — or
27/// `None` to stop this item's chain early.
28///
29/// Stages are pure spec-builders; the executor runs them. A stage can branch
30/// on the prior result (e.g. "verify the finding the review stage produced").
31pub type PipelineStage<I> =
32    Arc<dyn Fn(Option<&StepOutcome>, &I) -> Option<AgentStepSpec> + Send + Sync>;
33
34/// Run each item through `stages` as an independent chain.
35///
36/// All chains run concurrently, bounded by the executor's
37/// [`concurrency_hint`](AgentExecutor::concurrency_hint) — there is **no
38/// barrier between stages**, so item A can be in stage 3 while item B is still
39/// in stage 1. Wall-clock is the slowest single chain, not the
40/// sum-of-slowest-per-stage that a barrier `parallel` per stage would incur.
41///
42/// A chain stops early when a stage returns `None` or when a step fails
43/// (later stages would only build on a failed result). Returns each item's
44/// last outcome (`None` if its first stage produced no spec), preserving input
45/// order. A stage closure that panics isolates to that one chain (its result
46/// becomes `None`) without dropping the others.
47pub async fn execute_pipeline<I>(
48    executor: Arc<dyn AgentExecutor>,
49    items: Vec<I>,
50    stages: Vec<PipelineStage<I>>,
51    event_tx: Option<broadcast::Sender<AgentEvent>>,
52) -> Vec<Option<StepOutcome>>
53where
54    I: Send + 'static,
55{
56    let limit = executor.concurrency_hint();
57    let stages = Arc::new(stages);
58
59    let results = run_ordered_parallel_with_limit(items, limit, move |_idx, item| {
60        let executor = Arc::clone(&executor);
61        let stages = Arc::clone(&stages);
62        let event_tx = event_tx.clone();
63        async move {
64            let mut prev: Option<StepOutcome> = None;
65            for stage in stages.iter() {
66                let Some(spec) = stage(prev.as_ref(), &item) else {
67                    break;
68                };
69                let outcome = executor.execute_step(spec, event_tx.clone()).await;
70                let succeeded = outcome.success;
71                prev = Some(outcome);
72                if !succeeded {
73                    break;
74                }
75            }
76            prev
77        }
78    })
79    .await;
80
81    // A panicked chain (Err) yields `None`; a normal chain yields its last
82    // outcome. Order is preserved by `run_ordered_parallel_with_limit`.
83    results
84        .into_iter()
85        .map(|result| result.output.unwrap_or(None))
86        .collect()
87}
88
89/// Like [`execute_steps_parallel`](super::execute_steps_parallel), but
90/// **resumable**: progress is journaled to `store` under `workflow_id`, so an
91/// interrupted run picks up from the last completed step.
92///
93/// On entry, any steps already recorded in a prior checkpoint are skipped and
94/// their cached outcomes reused; only the rest are dispatched. As each step
95/// completes, the checkpoint is rewritten (the step boundary), so a crash
96/// mid-run loses at most the in-flight steps. Because the checkpoint is
97/// serializable and the executor is a parameter, a host can resume an
98/// interrupted workflow on a *different* node by passing that node's executor.
99///
100/// Results are returned in the original `specs` order. On full success the
101/// checkpoint is deleted (the workflow is terminal); only a crash leaves one
102/// behind for resume.
103pub async fn execute_steps_parallel_resumable(
104    executor: Arc<dyn AgentExecutor>,
105    specs: Vec<AgentStepSpec>,
106    workflow_id: &str,
107    store: Arc<dyn SessionStore>,
108    event_tx: Option<broadcast::Sender<AgentEvent>>,
109) -> Vec<StepOutcome> {
110    // Prior progress. A checkpoint is a side-effect boundary: if it cannot be
111    // decoded or its identity does not match the current specs, fail closed
112    // rather than re-running work whose external outcome is ambiguous.
113    let (done, completed_receipts): (
114        HashMap<String, StepOutcome>,
115        HashMap<String, ExecutionResultReceiptV1>,
116    ) = match store.load_workflow_checkpoint(workflow_id).await {
117        Ok(Some(cp)) => {
118            if let Err(error) = cp.validate_for_specs(workflow_id, &specs) {
119                tracing::warn!(
120                    workflow_id = %workflow_id,
121                    error = %error,
122                    "workflow checkpoint identity conflict; refusing to re-run"
123                );
124                return specs
125                    .into_iter()
126                    .map(|spec| {
127                        StepOutcome::failed(
128                            spec.task_id,
129                            spec.agent,
130                            format!("workflow checkpoint cannot be resumed: {error}"),
131                        )
132                    })
133                    .collect();
134            }
135            let receipts = cp
136                .steps
137                .iter()
138                .filter_map(|record| {
139                    record
140                        .result_receipt
141                        .clone()
142                        .map(|receipt| (record.task_id.clone(), receipt))
143                })
144                .collect();
145            (cp.completed(), receipts)
146        }
147        Ok(None) => (HashMap::new(), HashMap::new()),
148        Err(error) => {
149            tracing::warn!(
150                workflow_id = %workflow_id,
151                error = %error,
152                "workflow checkpoint unreadable; refusing to re-run"
153            );
154            return specs
155                .into_iter()
156                .map(|spec| {
157                    StepOutcome::failed(
158                        spec.task_id,
159                        spec.agent,
160                        format!("workflow checkpoint cannot be resumed: {error}"),
161                    )
162                })
163                .collect();
164        }
165    };
166
167    let pending: Vec<AgentStepSpec> = specs
168        .iter()
169        .filter(|s| !done.contains_key(&s.task_id))
170        .cloned()
171        .collect();
172    let labels: Vec<(String, String)> = pending
173        .iter()
174        .map(|s| (s.task_id.clone(), s.agent.clone()))
175        .collect();
176
177    // Accumulator seeded with prior progress; persisted at every step boundary.
178    let acc = Arc::new(tokio::sync::Mutex::new(done.clone()));
179    let receipt_acc = Arc::new(tokio::sync::Mutex::new(completed_receipts));
180    let limit = executor.concurrency_hint();
181    let workflow_id_owned = workflow_id.to_string();
182    let store_steps = Arc::clone(&store);
183
184    let results = run_ordered_parallel_with_limit(pending, limit, move |_idx, spec| {
185        let executor = Arc::clone(&executor);
186        let event_tx = event_tx.clone();
187        let acc = Arc::clone(&acc);
188        let receipt_acc = Arc::clone(&receipt_acc);
189        let store = Arc::clone(&store_steps);
190        let workflow_id = workflow_id_owned.clone();
191        async move {
192            let spec_for_receipt = spec.clone();
193            let outcome = executor.execute_step(spec, event_tx).await;
194            // Step boundary: record only *successful* steps, so a failed step
195            // is retried on resume (its effect didn't complete) while a
196            // succeeded step's work is never redone.
197            if outcome.success {
198                let mut guard = acc.lock().await;
199                guard.insert(outcome.task_id.clone(), outcome.clone());
200                let mut receipt_guard = receipt_acc.lock().await;
201                match workflow_step_result_receipt(&workflow_id, &spec_for_receipt, &outcome, None)
202                {
203                    Ok(receipt) => {
204                        receipt_guard.insert(outcome.task_id.clone(), receipt);
205                    }
206                    Err(error) => {
207                        receipt_guard.remove(&outcome.task_id);
208                        tracing::warn!(
209                            workflow_id = %workflow_id,
210                            task_id = %outcome.task_id,
211                            error = %error,
212                            "workflow result receipt unavailable; retaining legacy outcome"
213                        );
214                    }
215                }
216                let checkpoint = WorkflowCheckpoint::from_completed_with_receipts(
217                    &workflow_id,
218                    &guard,
219                    &receipt_guard,
220                    now_epoch_ms(),
221                );
222                if let Err(e) = store
223                    .save_workflow_checkpoint(&workflow_id, &checkpoint)
224                    .await
225                {
226                    // Losing a checkpoint must not fail the live run.
227                    tracing::warn!(
228                        workflow_id = %workflow_id,
229                        error = %e,
230                        "workflow checkpoint save failed; run continues"
231                    );
232                }
233            }
234            outcome
235        }
236    })
237    .await;
238
239    let mut fresh: HashMap<String, StepOutcome> = HashMap::new();
240    for result in results {
241        match result.output {
242            Ok(outcome) => {
243                fresh.insert(outcome.task_id.clone(), outcome);
244            }
245            Err(error) => {
246                if let Some((task_id, agent)) = labels.get(result.index).cloned() {
247                    fresh.insert(
248                        task_id.clone(),
249                        StepOutcome::failed(task_id, agent, error.to_string()),
250                    );
251                }
252            }
253        }
254    }
255
256    // Merge cached + freshly-run, in the original spec order.
257    let merged: Vec<StepOutcome> = specs
258        .iter()
259        .map(|s| {
260            done.get(&s.task_id)
261                .cloned()
262                .or_else(|| fresh.remove(&s.task_id))
263                .unwrap_or_else(|| {
264                    StepOutcome::failed(
265                        s.task_id.clone(),
266                        s.agent.clone(),
267                        "step produced no outcome",
268                    )
269                })
270        })
271        .collect();
272
273    if merged.iter().all(|o| o.success) {
274        let _ = store.delete_workflow_checkpoint(workflow_id).await;
275    }
276    merged
277}
278
279/// What an [`execute_loop`] predicate decides after seeing a round's outcomes.
280pub enum LoopDecision {
281    /// Run another round with these specs.
282    Continue(Vec<AgentStepSpec>),
283    /// Stop now; the loop returns the round that just completed.
284    Stop,
285}
286
287/// Run rounds until the predicate says [`Stop`](LoopDecision::Stop), a round is
288/// asked to run no specs, or `max_iterations` is reached — whichever comes
289/// first. Each round is a barrier ([`execute_steps_parallel`]); `next` receives
290/// the just-completed round's outcomes and decides whether (and with what) to
291/// continue. Returns the last round's outcomes (empty if `initial` was empty).
292///
293/// `max_iterations` is **mandatory and a hard cap**: it is clamped to at least
294/// 1, and once reached the loop stops even if the predicate returns
295/// [`Continue`](LoopDecision::Continue). This is the guard that makes an
296/// LLM-driven, unknown-length loop (e.g. loop-until-dry) safe — the predicate
297/// must never be the *only* termination condition.
298///
299/// This is the "loop" shape from the orchestration grammar; like the other
300/// combinators it is written purely against the [`AgentExecutor`] seam and adds
301/// no scheduling of its own.
302pub async fn execute_loop<F>(
303    executor: Arc<dyn AgentExecutor>,
304    initial: Vec<AgentStepSpec>,
305    max_iterations: usize,
306    event_tx: Option<broadcast::Sender<AgentEvent>>,
307    mut next: F,
308) -> Vec<StepOutcome>
309where
310    F: FnMut(&[StepOutcome]) -> LoopDecision + Send,
311{
312    let cap = max_iterations.max(1);
313    let mut specs = initial;
314    let mut last = Vec::new();
315    let mut iterations = 0;
316
317    while !specs.is_empty() {
318        let round = execute_steps_parallel(
319            Arc::clone(&executor),
320            std::mem::take(&mut specs),
321            event_tx.clone(),
322        )
323        .await;
324        iterations += 1;
325        let decision = next(&round);
326        last = round;
327        match decision {
328            LoopDecision::Continue(more) if iterations < cap => specs = more,
329            _ => break,
330        }
331    }
332
333    last
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use async_trait::async_trait;
340    use std::sync::atomic::{AtomicUsize, Ordering};
341    use std::time::Duration;
342
343    /// Echoes the prompt into the output; fails for agent `"fail"`; panics for
344    /// agent `"boom"`. Records peak concurrency.
345    struct EchoExecutor {
346        active: Arc<AtomicUsize>,
347        max_active: Arc<AtomicUsize>,
348    }
349
350    impl EchoExecutor {
351        fn new() -> Self {
352            Self {
353                active: Arc::new(AtomicUsize::new(0)),
354                max_active: Arc::new(AtomicUsize::new(0)),
355            }
356        }
357    }
358
359    #[async_trait]
360    impl AgentExecutor for EchoExecutor {
361        async fn execute_step(
362            &self,
363            spec: AgentStepSpec,
364            _event_tx: Option<broadcast::Sender<AgentEvent>>,
365        ) -> StepOutcome {
366            let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
367            self.max_active.fetch_max(now, Ordering::SeqCst);
368            tokio::time::sleep(Duration::from_millis(15)).await;
369            self.active.fetch_sub(1, Ordering::SeqCst);
370            assert!(spec.agent != "boom", "boom");
371            StepOutcome {
372                task_id: spec.task_id.clone(),
373                session_id: format!("task-run-{}", spec.task_id),
374                agent: spec.agent.clone(),
375                output: spec.prompt.clone(),
376                success: spec.agent != "fail",
377                structured: None,
378                source_anchors: Vec::new(),
379            }
380        }
381        fn concurrency_hint(&self) -> usize {
382            4
383        }
384    }
385
386    fn stage<I, F>(f: F) -> PipelineStage<I>
387    where
388        F: Fn(Option<&StepOutcome>, &I) -> Option<AgentStepSpec> + Send + Sync + 'static,
389    {
390        Arc::new(f)
391    }
392
393    #[tokio::test]
394    async fn each_item_chains_through_stages_and_later_stages_see_prior_output() {
395        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
396        // Stage 1: run agent "explore" with the item as the prompt.
397        // Stage 2: run agent "review" with a prompt derived from stage 1's output.
398        let stages = vec![
399            stage(|_prev: Option<&StepOutcome>, item: &&str| {
400                Some(AgentStepSpec::new("s1", "explore", "d", *item))
401            }),
402            stage(|prev: Option<&StepOutcome>, _item: &&str| {
403                let prior = prev.map(|o| o.output.clone()).unwrap_or_default();
404                Some(AgentStepSpec::new(
405                    "s2",
406                    "review",
407                    "d",
408                    format!("review of: {prior}"),
409                ))
410            }),
411        ];
412        let out = execute_pipeline(exec, vec!["alpha", "beta"], stages, None).await;
413
414        assert_eq!(out.len(), 2, "one result per item, order preserved");
415        // Each item's final outcome is stage 2, whose prompt was derived from
416        // stage 1's output (the item text).
417        assert_eq!(out[0].as_ref().unwrap().output, "review of: alpha");
418        assert_eq!(out[1].as_ref().unwrap().output, "review of: beta");
419        assert!(out.iter().all(|o| o.as_ref().unwrap().success));
420    }
421
422    #[tokio::test]
423    async fn chain_stops_on_failure_and_on_none_stage() {
424        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
425        // First item: stage 1 fails (agent "fail") → stage 2 must not run.
426        // Second item: stage 1 ok, stage 2 returns None → chain stops at stage 1.
427        let stages = vec![
428            stage(|_p: Option<&StepOutcome>, item: &&str| {
429                let agent = if *item == "x" { "fail" } else { "explore" };
430                Some(AgentStepSpec::new("s1", agent, "d", *item))
431            }),
432            stage(|_p: Option<&StepOutcome>, item: &&str| {
433                if *item == "y" {
434                    None // stop the second item's chain at stage 1
435                } else {
436                    Some(AgentStepSpec::new("s2", "review", "d", "second"))
437                }
438            }),
439        ];
440        let out = execute_pipeline(exec, vec!["x", "y"], stages, None).await;
441
442        let first = out[0].as_ref().unwrap();
443        assert!(!first.success, "failed stage 1 surfaces");
444        assert_eq!(
445            first.output, "x",
446            "stage 2 did not run after stage 1 failed"
447        );
448
449        let second = out[1].as_ref().unwrap();
450        assert!(second.success);
451        assert_eq!(
452            second.output, "y",
453            "stage 2 returned None → chain stopped at stage 1"
454        );
455    }
456
457    #[tokio::test]
458    async fn no_barrier_between_stages_bounded_by_hint() {
459        let echo = EchoExecutor::new();
460        let max_active = Arc::clone(&echo.max_active);
461        let exec: Arc<dyn AgentExecutor> = Arc::new(echo);
462        let stages = vec![
463            stage(|_p: Option<&StepOutcome>, item: &usize| {
464                Some(AgentStepSpec::new(
465                    format!("s1-{item}"),
466                    "explore",
467                    "d",
468                    "p",
469                ))
470            }),
471            stage(|_p: Option<&StepOutcome>, item: &usize| {
472                Some(AgentStepSpec::new(format!("s2-{item}"), "review", "d", "p"))
473            }),
474        ];
475        let items: Vec<usize> = (0..8).collect();
476        let out = execute_pipeline(exec, items, stages, None).await;
477        assert_eq!(out.len(), 8);
478        assert!(out.iter().all(|o| o.is_some()));
479        // concurrency_hint is 4: chains run concurrently but never exceed it.
480        assert!(
481            max_active.load(Ordering::SeqCst) <= 4,
482            "concurrency never exceeds the executor's hint"
483        );
484    }
485
486    #[tokio::test]
487    async fn panicking_stage_isolates_to_its_chain() {
488        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
489        let stages = vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
490            // The middle item routes to the panicking agent.
491            Some(AgentStepSpec::new("s1", *item, "d", "p"))
492        })];
493        let out = execute_pipeline(exec, vec!["explore", "boom", "review"], stages, None).await;
494        assert_eq!(out.len(), 3);
495        assert!(out[0].as_ref().unwrap().success);
496        assert!(out[1].is_none(), "panicked chain becomes None, not a drop");
497        assert!(out[2].as_ref().unwrap().success, "later chains unaffected");
498    }
499
500    /// Records which task ids it actually ran; always succeeds.
501    struct RecordingExecutor {
502        ran: Arc<tokio::sync::Mutex<Vec<String>>>,
503    }
504
505    #[async_trait]
506    impl AgentExecutor for RecordingExecutor {
507        async fn execute_step(
508            &self,
509            spec: AgentStepSpec,
510            _event_tx: Option<broadcast::Sender<AgentEvent>>,
511        ) -> StepOutcome {
512            self.ran.lock().await.push(spec.task_id.clone());
513            StepOutcome {
514                task_id: spec.task_id.clone(),
515                session_id: format!("task-run-{}", spec.task_id),
516                agent: spec.agent.clone(),
517                output: format!("ran:{}", spec.task_id),
518                success: true,
519                structured: None,
520                source_anchors: Vec::new(),
521            }
522        }
523        fn concurrency_hint(&self) -> usize {
524            4
525        }
526    }
527
528    #[tokio::test]
529    async fn resumable_skips_completed_then_clears_on_success() {
530        use crate::store::MemorySessionStore;
531        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
532
533        // Pre-seed: step "a" already completed on a prior run (possibly on
534        // another node — this exercises the migration path too).
535        let mut done = std::collections::HashMap::new();
536        done.insert(
537            "a".to_string(),
538            StepOutcome {
539                task_id: "a".into(),
540                session_id: "task-run-a".into(),
541                agent: "explore".into(),
542                output: "cached-a".into(),
543                success: true,
544                structured: None,
545                source_anchors: Vec::new(),
546            },
547        );
548        store
549            .save_workflow_checkpoint(
550                "wf-1",
551                &WorkflowCheckpoint::from_completed("wf-1", &done, 1),
552            )
553            .await
554            .unwrap();
555
556        // A FRESH executor resumes (the node that runs the rest is not the one
557        // that completed "a").
558        let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
559        let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
560            ran: Arc::clone(&ran),
561        });
562        let specs = vec![
563            AgentStepSpec::new("a", "explore", "d", "pa"),
564            AgentStepSpec::new("b", "review", "d", "pb"),
565        ];
566
567        let out =
568            execute_steps_parallel_resumable(exec, specs, "wf-1", Arc::clone(&store), None).await;
569
570        assert_eq!(
571            *ran.lock().await,
572            vec!["b".to_string()],
573            "only the not-yet-completed step runs"
574        );
575        assert_eq!(out.len(), 2);
576        assert_eq!(out[0].task_id, "a");
577        assert_eq!(
578            out[0].output, "cached-a",
579            "completed step returns its cached outcome, unchanged"
580        );
581        assert_eq!(out[1].task_id, "b");
582        assert!(out.iter().all(|o| o.success));
583        assert!(
584            store
585                .load_workflow_checkpoint("wf-1")
586                .await
587                .unwrap()
588                .is_none(),
589            "a fully-succeeded workflow clears its checkpoint"
590        );
591    }
592
593    #[tokio::test]
594    async fn resumable_fences_a_cached_result_when_step_identity_changes() {
595        use crate::store::MemorySessionStore;
596        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
597        let old_spec = AgentStepSpec::new("a", "explore", "old", "old prompt");
598        let old_outcome = cached("a", "explore", "cached-a");
599        let old_receipt = super::super::checkpoint::workflow_step_result_receipt(
600            "wf-stale",
601            &old_spec,
602            &old_outcome,
603            None,
604        )
605        .unwrap();
606        let mut done = HashMap::new();
607        done.insert("a".to_string(), old_outcome);
608        let mut receipts = HashMap::new();
609        receipts.insert("a".to_string(), old_receipt);
610        store
611            .save_workflow_checkpoint(
612                "wf-stale",
613                &WorkflowCheckpoint::from_completed_with_receipts("wf-stale", &done, &receipts, 1),
614            )
615            .await
616            .unwrap();
617
618        let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
619        let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
620            ran: Arc::clone(&ran),
621        });
622        let specs = vec![AgentStepSpec::new("a", "explore", "new", "new prompt")];
623        let out =
624            execute_steps_parallel_resumable(exec, specs, "wf-stale", Arc::clone(&store), None)
625                .await;
626
627        assert!(
628            ran.lock().await.is_empty(),
629            "stale cached work must not run"
630        );
631        assert_eq!(out.len(), 1);
632        assert!(!out[0].success);
633        assert!(out[0].output.contains("stale result identity"));
634    }
635
636    #[tokio::test]
637    async fn resumable_retains_checkpoint_recording_only_successes_on_partial_failure() {
638        use crate::store::MemorySessionStore;
639        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
640        // EchoExecutor fails the agent named "fail".
641        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
642        let specs = vec![
643            AgentStepSpec::new("ok", "explore", "d", "p"),
644            AgentStepSpec::new("bad", "fail", "d", "p"),
645        ];
646
647        let out =
648            execute_steps_parallel_resumable(exec, specs, "wf-2", Arc::clone(&store), None).await;
649        assert!(out[0].success);
650        assert!(!out[1].success);
651
652        // Not all succeeded → checkpoint retained, recording only the success
653        // so the failed step retries on resume.
654        let cp = store
655            .load_workflow_checkpoint("wf-2")
656            .await
657            .unwrap()
658            .expect("checkpoint retained on partial failure");
659        let completed = cp.completed();
660        assert!(completed.contains_key("ok"), "succeeded step is recorded");
661        assert!(
662            !completed.contains_key("bad"),
663            "failed step is NOT recorded → it retries on resume"
664        );
665        let ok_record = cp
666            .steps
667            .iter()
668            .find(|record| record.task_id == "ok")
669            .expect("successful step record");
670        let receipt = ok_record
671            .result_receipt
672            .as_ref()
673            .expect("successful step carries a bounded result receipt");
674        receipt.validate().unwrap();
675        assert!(receipt.result_digest.is_some());
676        assert!(receipt.result_bytes > 0);
677        assert!(!format!("{receipt:?}").contains("ran:ok"));
678    }
679
680    struct ZeroHintExecutor;
681    #[async_trait]
682    impl AgentExecutor for ZeroHintExecutor {
683        async fn execute_step(
684            &self,
685            spec: AgentStepSpec,
686            _event_tx: Option<broadcast::Sender<AgentEvent>>,
687        ) -> StepOutcome {
688            StepOutcome {
689                task_id: spec.task_id.clone(),
690                session_id: format!("task-run-{}", spec.task_id),
691                agent: spec.agent.clone(),
692                output: "ok".to_string(),
693                success: true,
694                structured: None,
695                source_anchors: Vec::new(),
696            }
697        }
698        fn concurrency_hint(&self) -> usize {
699            0
700        }
701    }
702
703    #[tokio::test]
704    async fn empty_inputs_return_empty() {
705        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
706        assert!(
707            crate::orchestration::execute_steps_parallel(Arc::clone(&exec), vec![], None)
708                .await
709                .is_empty()
710        );
711        let stages: Vec<PipelineStage<&str>> =
712            vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
713                Some(AgentStepSpec::new("s", "explore", "d", *item))
714            })];
715        assert!(execute_pipeline(exec, Vec::<&str>::new(), stages, None)
716            .await
717            .is_empty());
718    }
719
720    #[tokio::test]
721    async fn zero_concurrency_hint_still_makes_progress() {
722        // The .max(1) clamp in run_ordered_parallel_with_limit keeps a 0-hint
723        // executor serialized-but-live instead of deadlocking on 0 permits.
724        let exec: Arc<dyn AgentExecutor> = Arc::new(ZeroHintExecutor);
725        let specs = vec![
726            AgentStepSpec::new("a", "explore", "d", "p"),
727            AgentStepSpec::new("b", "explore", "d", "p"),
728            AgentStepSpec::new("c", "explore", "d", "p"),
729        ];
730        let out = crate::orchestration::execute_steps_parallel(exec, specs, None).await;
731        assert_eq!(
732            out.iter().map(|o| o.task_id.as_str()).collect::<Vec<_>>(),
733            vec!["a", "b", "c"]
734        );
735        assert!(out.iter().all(|o| o.success));
736    }
737
738    #[tokio::test]
739    async fn pipeline_first_stage_none_yields_none_outcome() {
740        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
741        let stages: Vec<PipelineStage<&str>> =
742            vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
743                if *item == "skip" {
744                    None
745                } else {
746                    Some(AgentStepSpec::new("s", "explore", "d", *item))
747                }
748            })];
749        let out = execute_pipeline(exec, vec!["skip", "run"], stages, None).await;
750        assert!(
751            out[0].is_none(),
752            "a first-stage None yields a None outcome (chain never started)"
753        );
754        assert!(out[1].as_ref().unwrap().success);
755    }
756
757    fn cached(task_id: &str, agent: &str, output: &str) -> StepOutcome {
758        StepOutcome {
759            task_id: task_id.to_string(),
760            session_id: format!("task-run-{task_id}"),
761            agent: agent.to_string(),
762            output: output.to_string(),
763            success: true,
764            structured: None,
765            source_anchors: Vec::new(),
766        }
767    }
768
769    #[tokio::test]
770    async fn resumable_fails_closed_when_checkpoint_load_errors() {
771        use crate::store::MemorySessionStore;
772        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
773
774        // A checkpoint written by a *newer*, incompatible schema version: the
775        // store rejects it on load. Re-running would risk duplicating an
776        // external side effect whose outcome is unknown, so the combinator
777        // fails closed and leaves the checkpoint for explicit reconciliation.
778        let mut done = std::collections::HashMap::new();
779        done.insert("a".to_string(), cached("a", "explore", "old"));
780        let mut cp = WorkflowCheckpoint::from_completed("wf-err", &done, 1);
781        cp.schema_version = crate::orchestration::WORKFLOW_CHECKPOINT_SCHEMA_VERSION + 1;
782        store.save_workflow_checkpoint("wf-err", &cp).await.unwrap();
783
784        let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
785        let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
786            ran: Arc::clone(&ran),
787        });
788        let specs = vec![
789            AgentStepSpec::new("a", "explore", "d", "pa"),
790            AgentStepSpec::new("b", "review", "d", "pb"),
791        ];
792        let out =
793            execute_steps_parallel_resumable(exec, specs, "wf-err", Arc::clone(&store), None).await;
794
795        assert!(
796            ran.lock().await.is_empty(),
797            "no step runs after load failure"
798        );
799        assert_eq!(out.len(), 2);
800        assert!(out.iter().all(|o| !o.success));
801        assert!(out.iter().all(|o| o.output.contains("cannot be resumed")));
802    }
803
804    #[tokio::test]
805    async fn resumable_ignores_checkpointed_steps_absent_from_new_specs() {
806        use crate::store::MemorySessionStore;
807        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
808
809        // Prior checkpoint completed {a, b}; the new run drops "a", reorders,
810        // and adds "c". Output follows the NEW specs; "b" is reused; the stale
811        // "a" simply doesn't appear; only "c" actually runs.
812        let mut done = std::collections::HashMap::new();
813        done.insert("a".to_string(), cached("a", "explore", "cached-a"));
814        done.insert("b".to_string(), cached("b", "review", "cached-b"));
815        store
816            .save_workflow_checkpoint(
817                "wf-x",
818                &WorkflowCheckpoint::from_completed("wf-x", &done, 1),
819            )
820            .await
821            .unwrap();
822
823        let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
824        let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
825            ran: Arc::clone(&ran),
826        });
827        let specs = vec![
828            AgentStepSpec::new("b", "review", "d", "pb"),
829            AgentStepSpec::new("c", "plan", "d", "pc"),
830        ];
831        let out =
832            execute_steps_parallel_resumable(exec, specs, "wf-x", Arc::clone(&store), None).await;
833
834        assert_eq!(
835            *ran.lock().await,
836            vec!["c".to_string()],
837            "cached b reused, stale a dropped, only new c runs"
838        );
839        assert_eq!(out.len(), 2);
840        assert_eq!(out[0].task_id, "b");
841        assert_eq!(out[0].output, "cached-b");
842        assert_eq!(out[1].task_id, "c");
843        assert!(out.iter().all(|o| o.success));
844    }
845
846    #[tokio::test]
847    async fn loop_stops_when_predicate_says_stop() {
848        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
849        let mut rounds = 0;
850        let out = crate::orchestration::execute_loop(
851            exec,
852            vec![AgentStepSpec::new("r0", "explore", "d", "p")],
853            10,
854            None,
855            |outcomes| {
856                rounds += 1;
857                // Continue twice, then stop on the third round.
858                if rounds < 3 {
859                    LoopDecision::Continue(vec![AgentStepSpec::new(
860                        format!("r{rounds}"),
861                        "explore",
862                        "d",
863                        outcomes[0].output.clone(),
864                    )])
865                } else {
866                    LoopDecision::Stop
867                }
868            },
869        )
870        .await;
871        assert_eq!(rounds, 3, "predicate saw exactly three rounds");
872        assert_eq!(out.len(), 1, "returns the last round's outcomes");
873        assert!(out[0].success);
874    }
875
876    #[tokio::test]
877    async fn loop_is_hard_capped_by_max_iterations() {
878        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
879        let mut rounds = 0;
880        // A predicate that NEVER stops — only max_iterations terminates it.
881        let _ = crate::orchestration::execute_loop(
882            exec,
883            vec![AgentStepSpec::new("r", "explore", "d", "p")],
884            3,
885            None,
886            |_outcomes| {
887                rounds += 1;
888                LoopDecision::Continue(vec![AgentStepSpec::new("r", "explore", "d", "p")])
889            },
890        )
891        .await;
892        assert_eq!(
893            rounds, 3,
894            "max_iterations is a hard cap on a never-stopping predicate"
895        );
896    }
897
898    #[tokio::test]
899    async fn loop_with_empty_initial_runs_nothing() {
900        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
901        let mut called = false;
902        let out = crate::orchestration::execute_loop(exec, vec![], 5, None, |_| {
903            called = true;
904            LoopDecision::Stop
905        })
906        .await;
907        assert!(out.is_empty());
908        assert!(!called, "predicate is not invoked when there is no work");
909    }
910
911    #[tokio::test]
912    async fn loop_stops_when_predicate_requests_no_further_specs() {
913        // Continue with an empty spec set ends the loop (no work left = dry).
914        let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
915        let mut rounds = 0;
916        let out = crate::orchestration::execute_loop(
917            exec,
918            vec![AgentStepSpec::new("r0", "explore", "d", "p")],
919            10,
920            None,
921            |_| {
922                rounds += 1;
923                LoopDecision::Continue(vec![]) // nothing more to do → loop ends
924            },
925        )
926        .await;
927        assert_eq!(rounds, 1);
928        assert_eq!(out.len(), 1, "the completed round is still returned");
929    }
930}