1use 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
36pub type PipelineStage<I> =
43 Arc<dyn Fn(Option<&StepOutcome>, &I) -> Option<AgentStepSpec> + Send + Sync>;
44
45pub 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 results
95 .into_iter()
96 .map(|result| result.output.unwrap_or(None))
97 .collect()
98}
99
100pub 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 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 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 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 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 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
301pub enum LoopDecision {
303 Continue(Vec<AgentStepSpec>),
305 Stop,
307}
308
309pub 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 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 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 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 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 } 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 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 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 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 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 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 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 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 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 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 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 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 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 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![]) },
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}