Skip to main content

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}