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