heartbit_core/agent/flow/parallel.rs
1//! [`parallel`] — a BARRIER fan-out that is fail-SOFT and submission-ordered.
2//!
3//! This is the deliberate dual of [`ParallelAgent`](crate::ParallelAgent):
4//! - `ParallelAgent` is fail-FAST (first `Err` aborts via `?`) and merges
5//! results sorted by agent name.
6//! - `parallel` is fail-SOFT: a thunk that returns `Err` *or panics* yields
7//! `None` in its slot, every other thunk still runs, and the call itself
8//! never rejects. Results stay in submission order.
9//!
10//! Filter the `None`s (`results.into_iter().flatten()`) before use.
11//!
12//! # Homogeneous vs heterogeneous thunks
13//!
14//! The common case — fanning out over a work-list — produces one closure type
15//! via `.map()` and needs no boxing:
16//!
17//! ```ignore
18//! let outs = parallel(&ctx, (0..n).map(|i| move || async move { work(i).await }).collect()).await;
19//! ```
20//!
21//! Distinct thunks have distinct types and cannot share a `Vec`. Wrap each in
22//! [`thunk`] to erase the type:
23//!
24//! ```ignore
25//! let outs = parallel(&ctx, vec![
26//! thunk({ let c = ctx.clone(); move || async move { agent(&c, "review auth").run().await } }),
27//! thunk({ let c = ctx.clone(); move || async move { agent(&c, "review perf").run().await } }),
28//! ]).await;
29//! ```
30
31use std::future::Future;
32use std::pin::Pin;
33
34use tokio::task::JoinSet;
35
36use crate::error::Error;
37
38use super::ctx::WorkflowCtx;
39
40/// A type-erased `parallel`/scatter thunk: a boxed nullary closure returning a
41/// boxed future. Use [`thunk`] to build one. Lets heterogeneous thunks share a
42/// single `Vec` (bare closures each have a distinct, unnameable type).
43pub type BoxThunk<R> = Box<
44 dyn FnOnce() -> Pin<Box<dyn Future<Output = Result<R, Error>> + Send + 'static>>
45 + Send
46 + 'static,
47>;
48
49/// Box a thunk into a [`BoxThunk`] so heterogeneous thunks can share a `Vec`.
50pub fn thunk<R, F, Fut>(f: F) -> BoxThunk<R>
51where
52 F: FnOnce() -> Fut + Send + 'static,
53 Fut: Future<Output = Result<R, Error>> + Send + 'static,
54 R: Send + 'static,
55{
56 Box::new(move || {
57 Box::pin(f()) as Pin<Box<dyn Future<Output = Result<R, Error>> + Send + 'static>>
58 })
59}
60
61/// Run `thunks` concurrently and await them all (a barrier), returning one slot
62/// per thunk in submission order. A thunk that errors or panics yields `None`.
63///
64/// `ctx` is threaded for API symmetry with the other combinators and so later
65/// phases can add cancellation / event emission without a signature change; the
66/// concurrency cap and budget are enforced at the [`agent`](super::agent::agent)
67/// leaf inside each thunk, not here.
68pub async fn parallel<R, F, Fut>(_ctx: &WorkflowCtx, thunks: Vec<F>) -> Vec<Option<R>>
69where
70 F: FnOnce() -> Fut + Send + 'static,
71 Fut: Future<Output = Result<R, Error>> + Send + 'static,
72 R: Send + 'static,
73{
74 let n = thunks.len();
75 let mut set = JoinSet::new();
76 for (i, thunk) in thunks.into_iter().enumerate() {
77 set.spawn(async move { (i, thunk().await) });
78 }
79
80 let mut out: Vec<Option<R>> = (0..n).map(|_| None).collect();
81 while let Some(joined) = set.join_next().await {
82 match joined {
83 // Thunk completed and returned Ok: place at its submission index.
84 Ok((i, Ok(value))) => out[i] = Some(value),
85 // Thunk returned Err: leave the slot None (fail-soft).
86 Ok((_i, Err(_e))) => {}
87 // Thunk panicked or was aborted: leave the slot None (isolation).
88 Err(_join_err) => {}
89 }
90 }
91 out
92}
93
94#[cfg(test)]
95mod tests {
96 use std::sync::Arc;
97 use std::time::Duration;
98
99 use super::*;
100 use crate::agent::test_helpers::MockProvider;
101 use crate::llm::BoxedProvider;
102
103 fn ctx() -> WorkflowCtx {
104 WorkflowCtx::builder(Arc::new(BoxedProvider::new(MockProvider::new(vec![]))))
105 .build()
106 .expect("build ctx")
107 }
108
109 #[tokio::test]
110 async fn results_in_submission_order_despite_completion_order() {
111 let ctx = ctx();
112 // Thunk 0 sleeps longest, thunk 2 returns immediately: completion order
113 // is the reverse of submission order, but results must match submission.
114 let thunks: Vec<BoxThunk<i32>> = vec![
115 thunk(|| async {
116 tokio::time::sleep(Duration::from_millis(40)).await;
117 Ok(10)
118 }),
119 thunk(|| async {
120 tokio::time::sleep(Duration::from_millis(20)).await;
121 Ok(20)
122 }),
123 thunk(|| async { Ok(30) }),
124 ];
125 let out = parallel(&ctx, thunks).await;
126 assert_eq!(out, vec![Some(10), Some(20), Some(30)]);
127 }
128
129 #[tokio::test]
130 async fn errored_thunk_yields_none_others_unaffected() {
131 let ctx = ctx();
132 let thunks: Vec<BoxThunk<i32>> = vec![
133 thunk(|| async { Ok(1) }),
134 thunk(|| async { Err(Error::Agent("boom".into())) }),
135 thunk(|| async { Ok(3) }),
136 ];
137 let out = parallel(&ctx, thunks).await;
138 // Never rejects; the failed slot is None, siblings are Some.
139 assert_eq!(out, vec![Some(1), None, Some(3)]);
140 }
141
142 // Multi-thread runtime so the panicking task runs on a tokio worker, not the
143 // libtest thread; `JoinSet` catches it into `Err(JoinError)` (our
144 // `Err(_join_err)` arm -> `None`). The deliberate panic still prints one line
145 // to stderr — that is expected, not a failure.
146 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
147 async fn panicking_thunk_is_isolated() {
148 let ctx = ctx();
149 let thunks: Vec<BoxThunk<i32>> = vec![
150 thunk(|| async { Ok(1) }),
151 thunk(|| async { panic!("intentional test panic") }),
152 thunk(|| async { Ok(3) }),
153 ];
154 let out = parallel(&ctx, thunks).await;
155 // JoinSet isolates the panic: that slot is None, siblings complete.
156 assert_eq!(out, vec![Some(1), None, Some(3)]);
157 }
158
159 #[tokio::test]
160 async fn homogeneous_map_fanout_needs_no_boxing() {
161 let ctx = ctx();
162 // The common case: map over a work-list, one closure type, no thunk().
163 let work = vec![1, 2, 3, 4];
164 let thunks: Vec<_> = work
165 .into_iter()
166 .map(|x| move || async move { Ok::<i32, Error>(x * 100) })
167 .collect();
168 let out = parallel(&ctx, thunks).await;
169 assert_eq!(out, vec![Some(100), Some(200), Some(300), Some(400)]);
170 }
171
172 #[tokio::test]
173 async fn empty_thunks_returns_empty() {
174 let ctx = ctx();
175 let thunks: Vec<BoxThunk<i32>> = Vec::new();
176 let out = parallel(&ctx, thunks).await;
177 assert!(out.is_empty());
178 }
179}