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