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;
15use std::sync::Arc;
16use tokio::sync::broadcast;
17
18fn now_epoch_ms() -> u64 {
19 std::time::SystemTime::now()
20 .duration_since(std::time::UNIX_EPOCH)
21 .map(|d| d.as_millis() as u64)
22 .unwrap_or(0)
23}
24
25pub type PipelineStage<I> =
32 Arc<dyn Fn(Option<&StepOutcome>, &I) -> Option<AgentStepSpec> + Send + Sync>;
33
34pub async fn execute_pipeline<I>(
48 executor: Arc<dyn AgentExecutor>,
49 items: Vec<I>,
50 stages: Vec<PipelineStage<I>>,
51 event_tx: Option<broadcast::Sender<AgentEvent>>,
52) -> Vec<Option<StepOutcome>>
53where
54 I: Send + 'static,
55{
56 let limit = executor.concurrency_hint();
57 let stages = Arc::new(stages);
58
59 let results = run_ordered_parallel_with_limit(items, limit, move |_idx, item| {
60 let executor = Arc::clone(&executor);
61 let stages = Arc::clone(&stages);
62 let event_tx = event_tx.clone();
63 async move {
64 let mut prev: Option<StepOutcome> = None;
65 for stage in stages.iter() {
66 let Some(spec) = stage(prev.as_ref(), &item) else {
67 break;
68 };
69 let outcome = executor.execute_step(spec, event_tx.clone()).await;
70 let succeeded = outcome.success;
71 prev = Some(outcome);
72 if !succeeded {
73 break;
74 }
75 }
76 prev
77 }
78 })
79 .await;
80
81 results
84 .into_iter()
85 .map(|result| result.output.unwrap_or(None))
86 .collect()
87}
88
89pub async fn execute_steps_parallel_resumable(
104 executor: Arc<dyn AgentExecutor>,
105 specs: Vec<AgentStepSpec>,
106 workflow_id: &str,
107 store: Arc<dyn SessionStore>,
108 event_tx: Option<broadcast::Sender<AgentEvent>>,
109) -> Vec<StepOutcome> {
110 let (done, completed_receipts): (
114 HashMap<String, StepOutcome>,
115 HashMap<String, ExecutionResultReceiptV1>,
116 ) = match store.load_workflow_checkpoint(workflow_id).await {
117 Ok(Some(cp)) => {
118 if let Err(error) = cp.validate_for_specs(workflow_id, &specs) {
119 tracing::warn!(
120 workflow_id = %workflow_id,
121 error = %error,
122 "workflow checkpoint identity conflict; refusing to re-run"
123 );
124 return specs
125 .into_iter()
126 .map(|spec| {
127 StepOutcome::failed(
128 spec.task_id,
129 spec.agent,
130 format!("workflow checkpoint cannot be resumed: {error}"),
131 )
132 })
133 .collect();
134 }
135 let receipts = cp
136 .steps
137 .iter()
138 .filter_map(|record| {
139 record
140 .result_receipt
141 .clone()
142 .map(|receipt| (record.task_id.clone(), receipt))
143 })
144 .collect();
145 (cp.completed(), receipts)
146 }
147 Ok(None) => (HashMap::new(), HashMap::new()),
148 Err(error) => {
149 tracing::warn!(
150 workflow_id = %workflow_id,
151 error = %error,
152 "workflow checkpoint unreadable; refusing to re-run"
153 );
154 return specs
155 .into_iter()
156 .map(|spec| {
157 StepOutcome::failed(
158 spec.task_id,
159 spec.agent,
160 format!("workflow checkpoint cannot be resumed: {error}"),
161 )
162 })
163 .collect();
164 }
165 };
166
167 let pending: Vec<AgentStepSpec> = specs
168 .iter()
169 .filter(|s| !done.contains_key(&s.task_id))
170 .cloned()
171 .collect();
172 let labels: Vec<(String, String)> = pending
173 .iter()
174 .map(|s| (s.task_id.clone(), s.agent.clone()))
175 .collect();
176
177 let acc = Arc::new(tokio::sync::Mutex::new(done.clone()));
179 let receipt_acc = Arc::new(tokio::sync::Mutex::new(completed_receipts));
180 let limit = executor.concurrency_hint();
181 let workflow_id_owned = workflow_id.to_string();
182 let store_steps = Arc::clone(&store);
183
184 let results = run_ordered_parallel_with_limit(pending, limit, move |_idx, spec| {
185 let executor = Arc::clone(&executor);
186 let event_tx = event_tx.clone();
187 let acc = Arc::clone(&acc);
188 let receipt_acc = Arc::clone(&receipt_acc);
189 let store = Arc::clone(&store_steps);
190 let workflow_id = workflow_id_owned.clone();
191 async move {
192 let spec_for_receipt = spec.clone();
193 let outcome = executor.execute_step(spec, event_tx).await;
194 if outcome.success {
198 let mut guard = acc.lock().await;
199 guard.insert(outcome.task_id.clone(), outcome.clone());
200 let mut receipt_guard = receipt_acc.lock().await;
201 match workflow_step_result_receipt(&workflow_id, &spec_for_receipt, &outcome, None)
202 {
203 Ok(receipt) => {
204 receipt_guard.insert(outcome.task_id.clone(), receipt);
205 }
206 Err(error) => {
207 receipt_guard.remove(&outcome.task_id);
208 tracing::warn!(
209 workflow_id = %workflow_id,
210 task_id = %outcome.task_id,
211 error = %error,
212 "workflow result receipt unavailable; retaining legacy outcome"
213 );
214 }
215 }
216 let checkpoint = WorkflowCheckpoint::from_completed_with_receipts(
217 &workflow_id,
218 &guard,
219 &receipt_guard,
220 now_epoch_ms(),
221 );
222 if let Err(e) = store
223 .save_workflow_checkpoint(&workflow_id, &checkpoint)
224 .await
225 {
226 tracing::warn!(
228 workflow_id = %workflow_id,
229 error = %e,
230 "workflow checkpoint save failed; run continues"
231 );
232 }
233 }
234 outcome
235 }
236 })
237 .await;
238
239 let mut fresh: HashMap<String, StepOutcome> = HashMap::new();
240 for result in results {
241 match result.output {
242 Ok(outcome) => {
243 fresh.insert(outcome.task_id.clone(), outcome);
244 }
245 Err(error) => {
246 if let Some((task_id, agent)) = labels.get(result.index).cloned() {
247 fresh.insert(
248 task_id.clone(),
249 StepOutcome::failed(task_id, agent, error.to_string()),
250 );
251 }
252 }
253 }
254 }
255
256 let merged: Vec<StepOutcome> = specs
258 .iter()
259 .map(|s| {
260 done.get(&s.task_id)
261 .cloned()
262 .or_else(|| fresh.remove(&s.task_id))
263 .unwrap_or_else(|| {
264 StepOutcome::failed(
265 s.task_id.clone(),
266 s.agent.clone(),
267 "step produced no outcome",
268 )
269 })
270 })
271 .collect();
272
273 if merged.iter().all(|o| o.success) {
274 let _ = store.delete_workflow_checkpoint(workflow_id).await;
275 }
276 merged
277}
278
279pub enum LoopDecision {
281 Continue(Vec<AgentStepSpec>),
283 Stop,
285}
286
287pub async fn execute_loop<F>(
303 executor: Arc<dyn AgentExecutor>,
304 initial: Vec<AgentStepSpec>,
305 max_iterations: usize,
306 event_tx: Option<broadcast::Sender<AgentEvent>>,
307 mut next: F,
308) -> Vec<StepOutcome>
309where
310 F: FnMut(&[StepOutcome]) -> LoopDecision + Send,
311{
312 let cap = max_iterations.max(1);
313 let mut specs = initial;
314 let mut last = Vec::new();
315 let mut iterations = 0;
316
317 while !specs.is_empty() {
318 let round = execute_steps_parallel(
319 Arc::clone(&executor),
320 std::mem::take(&mut specs),
321 event_tx.clone(),
322 )
323 .await;
324 iterations += 1;
325 let decision = next(&round);
326 last = round;
327 match decision {
328 LoopDecision::Continue(more) if iterations < cap => specs = more,
329 _ => break,
330 }
331 }
332
333 last
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use async_trait::async_trait;
340 use std::sync::atomic::{AtomicUsize, Ordering};
341 use std::time::Duration;
342
343 struct EchoExecutor {
346 active: Arc<AtomicUsize>,
347 max_active: Arc<AtomicUsize>,
348 }
349
350 impl EchoExecutor {
351 fn new() -> Self {
352 Self {
353 active: Arc::new(AtomicUsize::new(0)),
354 max_active: Arc::new(AtomicUsize::new(0)),
355 }
356 }
357 }
358
359 #[async_trait]
360 impl AgentExecutor for EchoExecutor {
361 async fn execute_step(
362 &self,
363 spec: AgentStepSpec,
364 _event_tx: Option<broadcast::Sender<AgentEvent>>,
365 ) -> StepOutcome {
366 let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
367 self.max_active.fetch_max(now, Ordering::SeqCst);
368 tokio::time::sleep(Duration::from_millis(15)).await;
369 self.active.fetch_sub(1, Ordering::SeqCst);
370 assert!(spec.agent != "boom", "boom");
371 StepOutcome {
372 task_id: spec.task_id.clone(),
373 session_id: format!("task-run-{}", spec.task_id),
374 agent: spec.agent.clone(),
375 output: spec.prompt.clone(),
376 success: spec.agent != "fail",
377 structured: None,
378 source_anchors: Vec::new(),
379 }
380 }
381 fn concurrency_hint(&self) -> usize {
382 4
383 }
384 }
385
386 fn stage<I, F>(f: F) -> PipelineStage<I>
387 where
388 F: Fn(Option<&StepOutcome>, &I) -> Option<AgentStepSpec> + Send + Sync + 'static,
389 {
390 Arc::new(f)
391 }
392
393 #[tokio::test]
394 async fn each_item_chains_through_stages_and_later_stages_see_prior_output() {
395 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
396 let stages = vec![
399 stage(|_prev: Option<&StepOutcome>, item: &&str| {
400 Some(AgentStepSpec::new("s1", "explore", "d", *item))
401 }),
402 stage(|prev: Option<&StepOutcome>, _item: &&str| {
403 let prior = prev.map(|o| o.output.clone()).unwrap_or_default();
404 Some(AgentStepSpec::new(
405 "s2",
406 "review",
407 "d",
408 format!("review of: {prior}"),
409 ))
410 }),
411 ];
412 let out = execute_pipeline(exec, vec!["alpha", "beta"], stages, None).await;
413
414 assert_eq!(out.len(), 2, "one result per item, order preserved");
415 assert_eq!(out[0].as_ref().unwrap().output, "review of: alpha");
418 assert_eq!(out[1].as_ref().unwrap().output, "review of: beta");
419 assert!(out.iter().all(|o| o.as_ref().unwrap().success));
420 }
421
422 #[tokio::test]
423 async fn chain_stops_on_failure_and_on_none_stage() {
424 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
425 let stages = vec![
428 stage(|_p: Option<&StepOutcome>, item: &&str| {
429 let agent = if *item == "x" { "fail" } else { "explore" };
430 Some(AgentStepSpec::new("s1", agent, "d", *item))
431 }),
432 stage(|_p: Option<&StepOutcome>, item: &&str| {
433 if *item == "y" {
434 None } else {
436 Some(AgentStepSpec::new("s2", "review", "d", "second"))
437 }
438 }),
439 ];
440 let out = execute_pipeline(exec, vec!["x", "y"], stages, None).await;
441
442 let first = out[0].as_ref().unwrap();
443 assert!(!first.success, "failed stage 1 surfaces");
444 assert_eq!(
445 first.output, "x",
446 "stage 2 did not run after stage 1 failed"
447 );
448
449 let second = out[1].as_ref().unwrap();
450 assert!(second.success);
451 assert_eq!(
452 second.output, "y",
453 "stage 2 returned None → chain stopped at stage 1"
454 );
455 }
456
457 #[tokio::test]
458 async fn no_barrier_between_stages_bounded_by_hint() {
459 let echo = EchoExecutor::new();
460 let max_active = Arc::clone(&echo.max_active);
461 let exec: Arc<dyn AgentExecutor> = Arc::new(echo);
462 let stages = vec![
463 stage(|_p: Option<&StepOutcome>, item: &usize| {
464 Some(AgentStepSpec::new(
465 format!("s1-{item}"),
466 "explore",
467 "d",
468 "p",
469 ))
470 }),
471 stage(|_p: Option<&StepOutcome>, item: &usize| {
472 Some(AgentStepSpec::new(format!("s2-{item}"), "review", "d", "p"))
473 }),
474 ];
475 let items: Vec<usize> = (0..8).collect();
476 let out = execute_pipeline(exec, items, stages, None).await;
477 assert_eq!(out.len(), 8);
478 assert!(out.iter().all(|o| o.is_some()));
479 assert!(
481 max_active.load(Ordering::SeqCst) <= 4,
482 "concurrency never exceeds the executor's hint"
483 );
484 }
485
486 #[tokio::test]
487 async fn panicking_stage_isolates_to_its_chain() {
488 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
489 let stages = vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
490 Some(AgentStepSpec::new("s1", *item, "d", "p"))
492 })];
493 let out = execute_pipeline(exec, vec!["explore", "boom", "review"], stages, None).await;
494 assert_eq!(out.len(), 3);
495 assert!(out[0].as_ref().unwrap().success);
496 assert!(out[1].is_none(), "panicked chain becomes None, not a drop");
497 assert!(out[2].as_ref().unwrap().success, "later chains unaffected");
498 }
499
500 struct RecordingExecutor {
502 ran: Arc<tokio::sync::Mutex<Vec<String>>>,
503 }
504
505 #[async_trait]
506 impl AgentExecutor for RecordingExecutor {
507 async fn execute_step(
508 &self,
509 spec: AgentStepSpec,
510 _event_tx: Option<broadcast::Sender<AgentEvent>>,
511 ) -> StepOutcome {
512 self.ran.lock().await.push(spec.task_id.clone());
513 StepOutcome {
514 task_id: spec.task_id.clone(),
515 session_id: format!("task-run-{}", spec.task_id),
516 agent: spec.agent.clone(),
517 output: format!("ran:{}", spec.task_id),
518 success: true,
519 structured: None,
520 source_anchors: Vec::new(),
521 }
522 }
523 fn concurrency_hint(&self) -> usize {
524 4
525 }
526 }
527
528 #[tokio::test]
529 async fn resumable_skips_completed_then_clears_on_success() {
530 use crate::store::MemorySessionStore;
531 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
532
533 let mut done = std::collections::HashMap::new();
536 done.insert(
537 "a".to_string(),
538 StepOutcome {
539 task_id: "a".into(),
540 session_id: "task-run-a".into(),
541 agent: "explore".into(),
542 output: "cached-a".into(),
543 success: true,
544 structured: None,
545 source_anchors: Vec::new(),
546 },
547 );
548 store
549 .save_workflow_checkpoint(
550 "wf-1",
551 &WorkflowCheckpoint::from_completed("wf-1", &done, 1),
552 )
553 .await
554 .unwrap();
555
556 let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
559 let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
560 ran: Arc::clone(&ran),
561 });
562 let specs = vec![
563 AgentStepSpec::new("a", "explore", "d", "pa"),
564 AgentStepSpec::new("b", "review", "d", "pb"),
565 ];
566
567 let out =
568 execute_steps_parallel_resumable(exec, specs, "wf-1", Arc::clone(&store), None).await;
569
570 assert_eq!(
571 *ran.lock().await,
572 vec!["b".to_string()],
573 "only the not-yet-completed step runs"
574 );
575 assert_eq!(out.len(), 2);
576 assert_eq!(out[0].task_id, "a");
577 assert_eq!(
578 out[0].output, "cached-a",
579 "completed step returns its cached outcome, unchanged"
580 );
581 assert_eq!(out[1].task_id, "b");
582 assert!(out.iter().all(|o| o.success));
583 assert!(
584 store
585 .load_workflow_checkpoint("wf-1")
586 .await
587 .unwrap()
588 .is_none(),
589 "a fully-succeeded workflow clears its checkpoint"
590 );
591 }
592
593 #[tokio::test]
594 async fn resumable_fences_a_cached_result_when_step_identity_changes() {
595 use crate::store::MemorySessionStore;
596 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
597 let old_spec = AgentStepSpec::new("a", "explore", "old", "old prompt");
598 let old_outcome = cached("a", "explore", "cached-a");
599 let old_receipt = super::super::checkpoint::workflow_step_result_receipt(
600 "wf-stale",
601 &old_spec,
602 &old_outcome,
603 None,
604 )
605 .unwrap();
606 let mut done = HashMap::new();
607 done.insert("a".to_string(), old_outcome);
608 let mut receipts = HashMap::new();
609 receipts.insert("a".to_string(), old_receipt);
610 store
611 .save_workflow_checkpoint(
612 "wf-stale",
613 &WorkflowCheckpoint::from_completed_with_receipts("wf-stale", &done, &receipts, 1),
614 )
615 .await
616 .unwrap();
617
618 let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
619 let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
620 ran: Arc::clone(&ran),
621 });
622 let specs = vec![AgentStepSpec::new("a", "explore", "new", "new prompt")];
623 let out =
624 execute_steps_parallel_resumable(exec, specs, "wf-stale", Arc::clone(&store), None)
625 .await;
626
627 assert!(
628 ran.lock().await.is_empty(),
629 "stale cached work must not run"
630 );
631 assert_eq!(out.len(), 1);
632 assert!(!out[0].success);
633 assert!(out[0].output.contains("stale result identity"));
634 }
635
636 #[tokio::test]
637 async fn resumable_retains_checkpoint_recording_only_successes_on_partial_failure() {
638 use crate::store::MemorySessionStore;
639 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
640 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
642 let specs = vec![
643 AgentStepSpec::new("ok", "explore", "d", "p"),
644 AgentStepSpec::new("bad", "fail", "d", "p"),
645 ];
646
647 let out =
648 execute_steps_parallel_resumable(exec, specs, "wf-2", Arc::clone(&store), None).await;
649 assert!(out[0].success);
650 assert!(!out[1].success);
651
652 let cp = store
655 .load_workflow_checkpoint("wf-2")
656 .await
657 .unwrap()
658 .expect("checkpoint retained on partial failure");
659 let completed = cp.completed();
660 assert!(completed.contains_key("ok"), "succeeded step is recorded");
661 assert!(
662 !completed.contains_key("bad"),
663 "failed step is NOT recorded → it retries on resume"
664 );
665 let ok_record = cp
666 .steps
667 .iter()
668 .find(|record| record.task_id == "ok")
669 .expect("successful step record");
670 let receipt = ok_record
671 .result_receipt
672 .as_ref()
673 .expect("successful step carries a bounded result receipt");
674 receipt.validate().unwrap();
675 assert!(receipt.result_digest.is_some());
676 assert!(receipt.result_bytes > 0);
677 assert!(!format!("{receipt:?}").contains("ran:ok"));
678 }
679
680 struct ZeroHintExecutor;
681 #[async_trait]
682 impl AgentExecutor for ZeroHintExecutor {
683 async fn execute_step(
684 &self,
685 spec: AgentStepSpec,
686 _event_tx: Option<broadcast::Sender<AgentEvent>>,
687 ) -> StepOutcome {
688 StepOutcome {
689 task_id: spec.task_id.clone(),
690 session_id: format!("task-run-{}", spec.task_id),
691 agent: spec.agent.clone(),
692 output: "ok".to_string(),
693 success: true,
694 structured: None,
695 source_anchors: Vec::new(),
696 }
697 }
698 fn concurrency_hint(&self) -> usize {
699 0
700 }
701 }
702
703 #[tokio::test]
704 async fn empty_inputs_return_empty() {
705 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
706 assert!(
707 crate::orchestration::execute_steps_parallel(Arc::clone(&exec), vec![], None)
708 .await
709 .is_empty()
710 );
711 let stages: Vec<PipelineStage<&str>> =
712 vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
713 Some(AgentStepSpec::new("s", "explore", "d", *item))
714 })];
715 assert!(execute_pipeline(exec, Vec::<&str>::new(), stages, None)
716 .await
717 .is_empty());
718 }
719
720 #[tokio::test]
721 async fn zero_concurrency_hint_still_makes_progress() {
722 let exec: Arc<dyn AgentExecutor> = Arc::new(ZeroHintExecutor);
725 let specs = vec![
726 AgentStepSpec::new("a", "explore", "d", "p"),
727 AgentStepSpec::new("b", "explore", "d", "p"),
728 AgentStepSpec::new("c", "explore", "d", "p"),
729 ];
730 let out = crate::orchestration::execute_steps_parallel(exec, specs, None).await;
731 assert_eq!(
732 out.iter().map(|o| o.task_id.as_str()).collect::<Vec<_>>(),
733 vec!["a", "b", "c"]
734 );
735 assert!(out.iter().all(|o| o.success));
736 }
737
738 #[tokio::test]
739 async fn pipeline_first_stage_none_yields_none_outcome() {
740 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
741 let stages: Vec<PipelineStage<&str>> =
742 vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
743 if *item == "skip" {
744 None
745 } else {
746 Some(AgentStepSpec::new("s", "explore", "d", *item))
747 }
748 })];
749 let out = execute_pipeline(exec, vec!["skip", "run"], stages, None).await;
750 assert!(
751 out[0].is_none(),
752 "a first-stage None yields a None outcome (chain never started)"
753 );
754 assert!(out[1].as_ref().unwrap().success);
755 }
756
757 fn cached(task_id: &str, agent: &str, output: &str) -> StepOutcome {
758 StepOutcome {
759 task_id: task_id.to_string(),
760 session_id: format!("task-run-{task_id}"),
761 agent: agent.to_string(),
762 output: output.to_string(),
763 success: true,
764 structured: None,
765 source_anchors: Vec::new(),
766 }
767 }
768
769 #[tokio::test]
770 async fn resumable_fails_closed_when_checkpoint_load_errors() {
771 use crate::store::MemorySessionStore;
772 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
773
774 let mut done = std::collections::HashMap::new();
779 done.insert("a".to_string(), cached("a", "explore", "old"));
780 let mut cp = WorkflowCheckpoint::from_completed("wf-err", &done, 1);
781 cp.schema_version = crate::orchestration::WORKFLOW_CHECKPOINT_SCHEMA_VERSION + 1;
782 store.save_workflow_checkpoint("wf-err", &cp).await.unwrap();
783
784 let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
785 let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
786 ran: Arc::clone(&ran),
787 });
788 let specs = vec![
789 AgentStepSpec::new("a", "explore", "d", "pa"),
790 AgentStepSpec::new("b", "review", "d", "pb"),
791 ];
792 let out =
793 execute_steps_parallel_resumable(exec, specs, "wf-err", Arc::clone(&store), None).await;
794
795 assert!(
796 ran.lock().await.is_empty(),
797 "no step runs after load failure"
798 );
799 assert_eq!(out.len(), 2);
800 assert!(out.iter().all(|o| !o.success));
801 assert!(out.iter().all(|o| o.output.contains("cannot be resumed")));
802 }
803
804 #[tokio::test]
805 async fn resumable_ignores_checkpointed_steps_absent_from_new_specs() {
806 use crate::store::MemorySessionStore;
807 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
808
809 let mut done = std::collections::HashMap::new();
813 done.insert("a".to_string(), cached("a", "explore", "cached-a"));
814 done.insert("b".to_string(), cached("b", "review", "cached-b"));
815 store
816 .save_workflow_checkpoint(
817 "wf-x",
818 &WorkflowCheckpoint::from_completed("wf-x", &done, 1),
819 )
820 .await
821 .unwrap();
822
823 let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
824 let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
825 ran: Arc::clone(&ran),
826 });
827 let specs = vec![
828 AgentStepSpec::new("b", "review", "d", "pb"),
829 AgentStepSpec::new("c", "plan", "d", "pc"),
830 ];
831 let out =
832 execute_steps_parallel_resumable(exec, specs, "wf-x", Arc::clone(&store), None).await;
833
834 assert_eq!(
835 *ran.lock().await,
836 vec!["c".to_string()],
837 "cached b reused, stale a dropped, only new c runs"
838 );
839 assert_eq!(out.len(), 2);
840 assert_eq!(out[0].task_id, "b");
841 assert_eq!(out[0].output, "cached-b");
842 assert_eq!(out[1].task_id, "c");
843 assert!(out.iter().all(|o| o.success));
844 }
845
846 #[tokio::test]
847 async fn loop_stops_when_predicate_says_stop() {
848 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
849 let mut rounds = 0;
850 let out = crate::orchestration::execute_loop(
851 exec,
852 vec![AgentStepSpec::new("r0", "explore", "d", "p")],
853 10,
854 None,
855 |outcomes| {
856 rounds += 1;
857 if rounds < 3 {
859 LoopDecision::Continue(vec![AgentStepSpec::new(
860 format!("r{rounds}"),
861 "explore",
862 "d",
863 outcomes[0].output.clone(),
864 )])
865 } else {
866 LoopDecision::Stop
867 }
868 },
869 )
870 .await;
871 assert_eq!(rounds, 3, "predicate saw exactly three rounds");
872 assert_eq!(out.len(), 1, "returns the last round's outcomes");
873 assert!(out[0].success);
874 }
875
876 #[tokio::test]
877 async fn loop_is_hard_capped_by_max_iterations() {
878 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
879 let mut rounds = 0;
880 let _ = crate::orchestration::execute_loop(
882 exec,
883 vec![AgentStepSpec::new("r", "explore", "d", "p")],
884 3,
885 None,
886 |_outcomes| {
887 rounds += 1;
888 LoopDecision::Continue(vec![AgentStepSpec::new("r", "explore", "d", "p")])
889 },
890 )
891 .await;
892 assert_eq!(
893 rounds, 3,
894 "max_iterations is a hard cap on a never-stopping predicate"
895 );
896 }
897
898 #[tokio::test]
899 async fn loop_with_empty_initial_runs_nothing() {
900 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
901 let mut called = false;
902 let out = crate::orchestration::execute_loop(exec, vec![], 5, None, |_| {
903 called = true;
904 LoopDecision::Stop
905 })
906 .await;
907 assert!(out.is_empty());
908 assert!(!called, "predicate is not invoked when there is no work");
909 }
910
911 #[tokio::test]
912 async fn loop_stops_when_predicate_requests_no_further_specs() {
913 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
915 let mut rounds = 0;
916 let out = crate::orchestration::execute_loop(
917 exec,
918 vec![AgentStepSpec::new("r0", "explore", "d", "p")],
919 10,
920 None,
921 |_| {
922 rounds += 1;
923 LoopDecision::Continue(vec![]) },
925 )
926 .await;
927 assert_eq!(rounds, 1);
928 assert_eq!(out.len(), 1, "the completed round is still returned");
929 }
930}