1use super::checkpoint::WorkflowCheckpoint;
9use super::executor::{execute_steps_parallel, AgentExecutor, AgentStepSpec, StepOutcome};
10use crate::agent::AgentEvent;
11use crate::ordered_parallel::run_ordered_parallel_with_limit;
12use crate::store::SessionStore;
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::broadcast;
16
17fn now_epoch_ms() -> u64 {
18 std::time::SystemTime::now()
19 .duration_since(std::time::UNIX_EPOCH)
20 .map(|d| d.as_millis() as u64)
21 .unwrap_or(0)
22}
23
24pub type PipelineStage<I> =
31 Arc<dyn Fn(Option<&StepOutcome>, &I) -> Option<AgentStepSpec> + Send + Sync>;
32
33pub async fn execute_pipeline<I>(
47 executor: Arc<dyn AgentExecutor>,
48 items: Vec<I>,
49 stages: Vec<PipelineStage<I>>,
50 event_tx: Option<broadcast::Sender<AgentEvent>>,
51) -> Vec<Option<StepOutcome>>
52where
53 I: Send + 'static,
54{
55 let limit = executor.concurrency_hint();
56 let stages = Arc::new(stages);
57
58 let results = run_ordered_parallel_with_limit(items, limit, move |_idx, item| {
59 let executor = Arc::clone(&executor);
60 let stages = Arc::clone(&stages);
61 let event_tx = event_tx.clone();
62 async move {
63 let mut prev: Option<StepOutcome> = None;
64 for stage in stages.iter() {
65 let Some(spec) = stage(prev.as_ref(), &item) else {
66 break;
67 };
68 let outcome = executor.execute_step(spec, event_tx.clone()).await;
69 let succeeded = outcome.success;
70 prev = Some(outcome);
71 if !succeeded {
72 break;
73 }
74 }
75 prev
76 }
77 })
78 .await;
79
80 results
83 .into_iter()
84 .map(|result| result.output.unwrap_or(None))
85 .collect()
86}
87
88pub async fn execute_steps_parallel_resumable(
103 executor: Arc<dyn AgentExecutor>,
104 specs: Vec<AgentStepSpec>,
105 workflow_id: &str,
106 store: Arc<dyn SessionStore>,
107 event_tx: Option<broadcast::Sender<AgentEvent>>,
108) -> Vec<StepOutcome> {
109 let done: HashMap<String, StepOutcome> = match store.load_workflow_checkpoint(workflow_id).await
115 {
116 Ok(Some(cp)) => cp.completed(),
117 Ok(None) => HashMap::new(),
118 Err(e) => {
119 tracing::warn!(
120 workflow_id = %workflow_id,
121 error = %e,
122 "workflow checkpoint unreadable; re-running the workflow from scratch"
123 );
124 HashMap::new()
125 }
126 };
127
128 let pending: Vec<AgentStepSpec> = specs
129 .iter()
130 .filter(|s| !done.contains_key(&s.task_id))
131 .cloned()
132 .collect();
133 let labels: Vec<(String, String)> = pending
134 .iter()
135 .map(|s| (s.task_id.clone(), s.agent.clone()))
136 .collect();
137
138 let acc = Arc::new(tokio::sync::Mutex::new(done.clone()));
140 let limit = executor.concurrency_hint();
141 let workflow_id_owned = workflow_id.to_string();
142 let store_steps = Arc::clone(&store);
143
144 let results = run_ordered_parallel_with_limit(pending, limit, move |_idx, spec| {
145 let executor = Arc::clone(&executor);
146 let event_tx = event_tx.clone();
147 let acc = Arc::clone(&acc);
148 let store = Arc::clone(&store_steps);
149 let workflow_id = workflow_id_owned.clone();
150 async move {
151 let outcome = executor.execute_step(spec, event_tx).await;
152 if outcome.success {
156 let mut guard = acc.lock().await;
157 guard.insert(outcome.task_id.clone(), outcome.clone());
158 let checkpoint =
159 WorkflowCheckpoint::from_completed(&workflow_id, &guard, now_epoch_ms());
160 if let Err(e) = store
161 .save_workflow_checkpoint(&workflow_id, &checkpoint)
162 .await
163 {
164 tracing::warn!(
166 workflow_id = %workflow_id,
167 error = %e,
168 "workflow checkpoint save failed; run continues"
169 );
170 }
171 }
172 outcome
173 }
174 })
175 .await;
176
177 let mut fresh: HashMap<String, StepOutcome> = HashMap::new();
178 for result in results {
179 match result.output {
180 Ok(outcome) => {
181 fresh.insert(outcome.task_id.clone(), outcome);
182 }
183 Err(error) => {
184 if let Some((task_id, agent)) = labels.get(result.index).cloned() {
185 fresh.insert(
186 task_id.clone(),
187 StepOutcome::failed(task_id, agent, error.to_string()),
188 );
189 }
190 }
191 }
192 }
193
194 let merged: Vec<StepOutcome> = specs
196 .iter()
197 .map(|s| {
198 done.get(&s.task_id)
199 .cloned()
200 .or_else(|| fresh.remove(&s.task_id))
201 .unwrap_or_else(|| {
202 StepOutcome::failed(
203 s.task_id.clone(),
204 s.agent.clone(),
205 "step produced no outcome",
206 )
207 })
208 })
209 .collect();
210
211 if merged.iter().all(|o| o.success) {
212 let _ = store.delete_workflow_checkpoint(workflow_id).await;
213 }
214 merged
215}
216
217pub enum LoopDecision {
219 Continue(Vec<AgentStepSpec>),
221 Stop,
223}
224
225pub async fn execute_loop<F>(
241 executor: Arc<dyn AgentExecutor>,
242 initial: Vec<AgentStepSpec>,
243 max_iterations: usize,
244 event_tx: Option<broadcast::Sender<AgentEvent>>,
245 mut next: F,
246) -> Vec<StepOutcome>
247where
248 F: FnMut(&[StepOutcome]) -> LoopDecision + Send,
249{
250 let cap = max_iterations.max(1);
251 let mut specs = initial;
252 let mut last = Vec::new();
253 let mut iterations = 0;
254
255 while !specs.is_empty() {
256 let round = execute_steps_parallel(
257 Arc::clone(&executor),
258 std::mem::take(&mut specs),
259 event_tx.clone(),
260 )
261 .await;
262 iterations += 1;
263 let decision = next(&round);
264 last = round;
265 match decision {
266 LoopDecision::Continue(more) if iterations < cap => specs = more,
267 _ => break,
268 }
269 }
270
271 last
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use async_trait::async_trait;
278 use std::sync::atomic::{AtomicUsize, Ordering};
279 use std::time::Duration;
280
281 struct EchoExecutor {
284 active: Arc<AtomicUsize>,
285 max_active: Arc<AtomicUsize>,
286 }
287
288 impl EchoExecutor {
289 fn new() -> Self {
290 Self {
291 active: Arc::new(AtomicUsize::new(0)),
292 max_active: Arc::new(AtomicUsize::new(0)),
293 }
294 }
295 }
296
297 #[async_trait]
298 impl AgentExecutor for EchoExecutor {
299 async fn execute_step(
300 &self,
301 spec: AgentStepSpec,
302 _event_tx: Option<broadcast::Sender<AgentEvent>>,
303 ) -> StepOutcome {
304 let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
305 self.max_active.fetch_max(now, Ordering::SeqCst);
306 tokio::time::sleep(Duration::from_millis(15)).await;
307 self.active.fetch_sub(1, Ordering::SeqCst);
308 assert!(spec.agent != "boom", "boom");
309 StepOutcome {
310 task_id: spec.task_id.clone(),
311 session_id: format!("task-run-{}", spec.task_id),
312 agent: spec.agent.clone(),
313 output: spec.prompt.clone(),
314 success: spec.agent != "fail",
315 structured: None,
316 source_anchors: Vec::new(),
317 }
318 }
319 fn concurrency_hint(&self) -> usize {
320 4
321 }
322 }
323
324 fn stage<I, F>(f: F) -> PipelineStage<I>
325 where
326 F: Fn(Option<&StepOutcome>, &I) -> Option<AgentStepSpec> + Send + Sync + 'static,
327 {
328 Arc::new(f)
329 }
330
331 #[tokio::test]
332 async fn each_item_chains_through_stages_and_later_stages_see_prior_output() {
333 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
334 let stages = vec![
337 stage(|_prev: Option<&StepOutcome>, item: &&str| {
338 Some(AgentStepSpec::new("s1", "explore", "d", *item))
339 }),
340 stage(|prev: Option<&StepOutcome>, _item: &&str| {
341 let prior = prev.map(|o| o.output.clone()).unwrap_or_default();
342 Some(AgentStepSpec::new(
343 "s2",
344 "review",
345 "d",
346 format!("review of: {prior}"),
347 ))
348 }),
349 ];
350 let out = execute_pipeline(exec, vec!["alpha", "beta"], stages, None).await;
351
352 assert_eq!(out.len(), 2, "one result per item, order preserved");
353 assert_eq!(out[0].as_ref().unwrap().output, "review of: alpha");
356 assert_eq!(out[1].as_ref().unwrap().output, "review of: beta");
357 assert!(out.iter().all(|o| o.as_ref().unwrap().success));
358 }
359
360 #[tokio::test]
361 async fn chain_stops_on_failure_and_on_none_stage() {
362 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
363 let stages = vec![
366 stage(|_p: Option<&StepOutcome>, item: &&str| {
367 let agent = if *item == "x" { "fail" } else { "explore" };
368 Some(AgentStepSpec::new("s1", agent, "d", *item))
369 }),
370 stage(|_p: Option<&StepOutcome>, item: &&str| {
371 if *item == "y" {
372 None } else {
374 Some(AgentStepSpec::new("s2", "review", "d", "second"))
375 }
376 }),
377 ];
378 let out = execute_pipeline(exec, vec!["x", "y"], stages, None).await;
379
380 let first = out[0].as_ref().unwrap();
381 assert!(!first.success, "failed stage 1 surfaces");
382 assert_eq!(
383 first.output, "x",
384 "stage 2 did not run after stage 1 failed"
385 );
386
387 let second = out[1].as_ref().unwrap();
388 assert!(second.success);
389 assert_eq!(
390 second.output, "y",
391 "stage 2 returned None → chain stopped at stage 1"
392 );
393 }
394
395 #[tokio::test]
396 async fn no_barrier_between_stages_bounded_by_hint() {
397 let echo = EchoExecutor::new();
398 let max_active = Arc::clone(&echo.max_active);
399 let exec: Arc<dyn AgentExecutor> = Arc::new(echo);
400 let stages = vec![
401 stage(|_p: Option<&StepOutcome>, item: &usize| {
402 Some(AgentStepSpec::new(
403 format!("s1-{item}"),
404 "explore",
405 "d",
406 "p",
407 ))
408 }),
409 stage(|_p: Option<&StepOutcome>, item: &usize| {
410 Some(AgentStepSpec::new(format!("s2-{item}"), "review", "d", "p"))
411 }),
412 ];
413 let items: Vec<usize> = (0..8).collect();
414 let out = execute_pipeline(exec, items, stages, None).await;
415 assert_eq!(out.len(), 8);
416 assert!(out.iter().all(|o| o.is_some()));
417 assert!(
419 max_active.load(Ordering::SeqCst) <= 4,
420 "concurrency never exceeds the executor's hint"
421 );
422 }
423
424 #[tokio::test]
425 async fn panicking_stage_isolates_to_its_chain() {
426 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
427 let stages = vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
428 Some(AgentStepSpec::new("s1", *item, "d", "p"))
430 })];
431 let out = execute_pipeline(exec, vec!["explore", "boom", "review"], stages, None).await;
432 assert_eq!(out.len(), 3);
433 assert!(out[0].as_ref().unwrap().success);
434 assert!(out[1].is_none(), "panicked chain becomes None, not a drop");
435 assert!(out[2].as_ref().unwrap().success, "later chains unaffected");
436 }
437
438 struct RecordingExecutor {
440 ran: Arc<tokio::sync::Mutex<Vec<String>>>,
441 }
442
443 #[async_trait]
444 impl AgentExecutor for RecordingExecutor {
445 async fn execute_step(
446 &self,
447 spec: AgentStepSpec,
448 _event_tx: Option<broadcast::Sender<AgentEvent>>,
449 ) -> StepOutcome {
450 self.ran.lock().await.push(spec.task_id.clone());
451 StepOutcome {
452 task_id: spec.task_id.clone(),
453 session_id: format!("task-run-{}", spec.task_id),
454 agent: spec.agent.clone(),
455 output: format!("ran:{}", spec.task_id),
456 success: true,
457 structured: None,
458 source_anchors: Vec::new(),
459 }
460 }
461 fn concurrency_hint(&self) -> usize {
462 4
463 }
464 }
465
466 #[tokio::test]
467 async fn resumable_skips_completed_then_clears_on_success() {
468 use crate::store::MemorySessionStore;
469 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
470
471 let mut done = std::collections::HashMap::new();
474 done.insert(
475 "a".to_string(),
476 StepOutcome {
477 task_id: "a".into(),
478 session_id: "task-run-a".into(),
479 agent: "explore".into(),
480 output: "cached-a".into(),
481 success: true,
482 structured: None,
483 source_anchors: Vec::new(),
484 },
485 );
486 store
487 .save_workflow_checkpoint(
488 "wf-1",
489 &WorkflowCheckpoint::from_completed("wf-1", &done, 1),
490 )
491 .await
492 .unwrap();
493
494 let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
497 let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
498 ran: Arc::clone(&ran),
499 });
500 let specs = vec![
501 AgentStepSpec::new("a", "explore", "d", "pa"),
502 AgentStepSpec::new("b", "review", "d", "pb"),
503 ];
504
505 let out =
506 execute_steps_parallel_resumable(exec, specs, "wf-1", Arc::clone(&store), None).await;
507
508 assert_eq!(
509 *ran.lock().await,
510 vec!["b".to_string()],
511 "only the not-yet-completed step runs"
512 );
513 assert_eq!(out.len(), 2);
514 assert_eq!(out[0].task_id, "a");
515 assert_eq!(
516 out[0].output, "cached-a",
517 "completed step returns its cached outcome, unchanged"
518 );
519 assert_eq!(out[1].task_id, "b");
520 assert!(out.iter().all(|o| o.success));
521 assert!(
522 store
523 .load_workflow_checkpoint("wf-1")
524 .await
525 .unwrap()
526 .is_none(),
527 "a fully-succeeded workflow clears its checkpoint"
528 );
529 }
530
531 #[tokio::test]
532 async fn resumable_retains_checkpoint_recording_only_successes_on_partial_failure() {
533 use crate::store::MemorySessionStore;
534 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
535 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
537 let specs = vec![
538 AgentStepSpec::new("ok", "explore", "d", "p"),
539 AgentStepSpec::new("bad", "fail", "d", "p"),
540 ];
541
542 let out =
543 execute_steps_parallel_resumable(exec, specs, "wf-2", Arc::clone(&store), None).await;
544 assert!(out[0].success);
545 assert!(!out[1].success);
546
547 let cp = store
550 .load_workflow_checkpoint("wf-2")
551 .await
552 .unwrap()
553 .expect("checkpoint retained on partial failure");
554 let completed = cp.completed();
555 assert!(completed.contains_key("ok"), "succeeded step is recorded");
556 assert!(
557 !completed.contains_key("bad"),
558 "failed step is NOT recorded → it retries on resume"
559 );
560 }
561
562 struct ZeroHintExecutor;
563 #[async_trait]
564 impl AgentExecutor for ZeroHintExecutor {
565 async fn execute_step(
566 &self,
567 spec: AgentStepSpec,
568 _event_tx: Option<broadcast::Sender<AgentEvent>>,
569 ) -> StepOutcome {
570 StepOutcome {
571 task_id: spec.task_id.clone(),
572 session_id: format!("task-run-{}", spec.task_id),
573 agent: spec.agent.clone(),
574 output: "ok".to_string(),
575 success: true,
576 structured: None,
577 source_anchors: Vec::new(),
578 }
579 }
580 fn concurrency_hint(&self) -> usize {
581 0
582 }
583 }
584
585 #[tokio::test]
586 async fn empty_inputs_return_empty() {
587 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
588 assert!(
589 crate::orchestration::execute_steps_parallel(Arc::clone(&exec), vec![], None)
590 .await
591 .is_empty()
592 );
593 let stages: Vec<PipelineStage<&str>> =
594 vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
595 Some(AgentStepSpec::new("s", "explore", "d", *item))
596 })];
597 assert!(execute_pipeline(exec, Vec::<&str>::new(), stages, None)
598 .await
599 .is_empty());
600 }
601
602 #[tokio::test]
603 async fn zero_concurrency_hint_still_makes_progress() {
604 let exec: Arc<dyn AgentExecutor> = Arc::new(ZeroHintExecutor);
607 let specs = vec![
608 AgentStepSpec::new("a", "explore", "d", "p"),
609 AgentStepSpec::new("b", "explore", "d", "p"),
610 AgentStepSpec::new("c", "explore", "d", "p"),
611 ];
612 let out = crate::orchestration::execute_steps_parallel(exec, specs, None).await;
613 assert_eq!(
614 out.iter().map(|o| o.task_id.as_str()).collect::<Vec<_>>(),
615 vec!["a", "b", "c"]
616 );
617 assert!(out.iter().all(|o| o.success));
618 }
619
620 #[tokio::test]
621 async fn pipeline_first_stage_none_yields_none_outcome() {
622 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
623 let stages: Vec<PipelineStage<&str>> =
624 vec![stage(|_p: Option<&StepOutcome>, item: &&str| {
625 if *item == "skip" {
626 None
627 } else {
628 Some(AgentStepSpec::new("s", "explore", "d", *item))
629 }
630 })];
631 let out = execute_pipeline(exec, vec!["skip", "run"], stages, None).await;
632 assert!(
633 out[0].is_none(),
634 "a first-stage None yields a None outcome (chain never started)"
635 );
636 assert!(out[1].as_ref().unwrap().success);
637 }
638
639 fn cached(task_id: &str, agent: &str, output: &str) -> StepOutcome {
640 StepOutcome {
641 task_id: task_id.to_string(),
642 session_id: format!("task-run-{task_id}"),
643 agent: agent.to_string(),
644 output: output.to_string(),
645 success: true,
646 structured: None,
647 source_anchors: Vec::new(),
648 }
649 }
650
651 #[tokio::test]
652 async fn resumable_reruns_all_when_checkpoint_load_errors() {
653 use crate::store::MemorySessionStore;
654 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
655
656 let mut done = std::collections::HashMap::new();
661 done.insert("a".to_string(), cached("a", "explore", "old"));
662 let mut cp = WorkflowCheckpoint::from_completed("wf-err", &done, 1);
663 cp.schema_version = crate::orchestration::WORKFLOW_CHECKPOINT_SCHEMA_VERSION + 1;
664 store.save_workflow_checkpoint("wf-err", &cp).await.unwrap();
665
666 let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
667 let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
668 ran: Arc::clone(&ran),
669 });
670 let specs = vec![
671 AgentStepSpec::new("a", "explore", "d", "pa"),
672 AgentStepSpec::new("b", "review", "d", "pb"),
673 ];
674 let out =
675 execute_steps_parallel_resumable(exec, specs, "wf-err", Arc::clone(&store), None).await;
676
677 let mut ran_ids = ran.lock().await.clone();
678 ran_ids.sort();
679 assert_eq!(
680 ran_ids,
681 vec!["a".to_string(), "b".to_string()],
682 "an unreadable (future-version) checkpoint is ignored → all steps re-run"
683 );
684 assert_eq!(out.len(), 2);
685 assert!(out.iter().all(|o| o.success));
686 }
687
688 #[tokio::test]
689 async fn resumable_ignores_checkpointed_steps_absent_from_new_specs() {
690 use crate::store::MemorySessionStore;
691 let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
692
693 let mut done = std::collections::HashMap::new();
697 done.insert("a".to_string(), cached("a", "explore", "cached-a"));
698 done.insert("b".to_string(), cached("b", "review", "cached-b"));
699 store
700 .save_workflow_checkpoint(
701 "wf-x",
702 &WorkflowCheckpoint::from_completed("wf-x", &done, 1),
703 )
704 .await
705 .unwrap();
706
707 let ran = Arc::new(tokio::sync::Mutex::new(Vec::new()));
708 let exec: Arc<dyn AgentExecutor> = Arc::new(RecordingExecutor {
709 ran: Arc::clone(&ran),
710 });
711 let specs = vec![
712 AgentStepSpec::new("b", "review", "d", "pb"),
713 AgentStepSpec::new("c", "plan", "d", "pc"),
714 ];
715 let out =
716 execute_steps_parallel_resumable(exec, specs, "wf-x", Arc::clone(&store), None).await;
717
718 assert_eq!(
719 *ran.lock().await,
720 vec!["c".to_string()],
721 "cached b reused, stale a dropped, only new c runs"
722 );
723 assert_eq!(out.len(), 2);
724 assert_eq!(out[0].task_id, "b");
725 assert_eq!(out[0].output, "cached-b");
726 assert_eq!(out[1].task_id, "c");
727 assert!(out.iter().all(|o| o.success));
728 }
729
730 #[tokio::test]
731 async fn loop_stops_when_predicate_says_stop() {
732 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
733 let mut rounds = 0;
734 let out = crate::orchestration::execute_loop(
735 exec,
736 vec![AgentStepSpec::new("r0", "explore", "d", "p")],
737 10,
738 None,
739 |outcomes| {
740 rounds += 1;
741 if rounds < 3 {
743 LoopDecision::Continue(vec![AgentStepSpec::new(
744 format!("r{rounds}"),
745 "explore",
746 "d",
747 outcomes[0].output.clone(),
748 )])
749 } else {
750 LoopDecision::Stop
751 }
752 },
753 )
754 .await;
755 assert_eq!(rounds, 3, "predicate saw exactly three rounds");
756 assert_eq!(out.len(), 1, "returns the last round's outcomes");
757 assert!(out[0].success);
758 }
759
760 #[tokio::test]
761 async fn loop_is_hard_capped_by_max_iterations() {
762 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
763 let mut rounds = 0;
764 let _ = crate::orchestration::execute_loop(
766 exec,
767 vec![AgentStepSpec::new("r", "explore", "d", "p")],
768 3,
769 None,
770 |_outcomes| {
771 rounds += 1;
772 LoopDecision::Continue(vec![AgentStepSpec::new("r", "explore", "d", "p")])
773 },
774 )
775 .await;
776 assert_eq!(
777 rounds, 3,
778 "max_iterations is a hard cap on a never-stopping predicate"
779 );
780 }
781
782 #[tokio::test]
783 async fn loop_with_empty_initial_runs_nothing() {
784 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
785 let mut called = false;
786 let out = crate::orchestration::execute_loop(exec, vec![], 5, None, |_| {
787 called = true;
788 LoopDecision::Stop
789 })
790 .await;
791 assert!(out.is_empty());
792 assert!(!called, "predicate is not invoked when there is no work");
793 }
794
795 #[tokio::test]
796 async fn loop_stops_when_predicate_requests_no_further_specs() {
797 let exec: Arc<dyn AgentExecutor> = Arc::new(EchoExecutor::new());
799 let mut rounds = 0;
800 let out = crate::orchestration::execute_loop(
801 exec,
802 vec![AgentStepSpec::new("r0", "explore", "d", "p")],
803 10,
804 None,
805 |_| {
806 rounds += 1;
807 LoopDecision::Continue(vec![]) },
809 )
810 .await;
811 assert_eq!(rounds, 1);
812 assert_eq!(out.len(), 1, "the completed round is still returned");
813 }
814}