ironflow_engine/context/steps/sub_workflow/wave.rs
1//! A wave of sub-workflows of one handler, run concurrently.
2//!
3//! Every child gets a `Workflow` step at the same position. Steps and child
4//! runs are created in list order, then the children execute concurrently in
5//! the parent's task: each child future borrows the parent context, like a
6//! single `workflow_with` does. The parent closes the steps in list order.
7
8use std::collections::HashSet;
9use std::sync::{Arc, Mutex};
10
11use futures_util::future::join_all;
12use serde_json::to_value;
13use uuid::Uuid;
14
15use ironflow_store::models::{Step, StepKind, StepStatus};
16
17use crate::config::{WorkflowOptions, WorkflowStepConfig};
18use crate::context::WorkflowContext;
19use crate::context::cost::SharedCost;
20use crate::context::lifecycle::check_replay_identity;
21use crate::error::EngineError;
22use crate::executor::SubWorkflowOutcome;
23use crate::guard::{SharedGuardState, WorkflowRejection};
24use crate::handler::{TypedWorkflow, WorkflowHandler};
25use crate::plan::{SharedPlanRecorder, lock_plan};
26
27use super::child::ChildStart;
28use super::child_run::ChildRun;
29use super::{ChildResume, leaves_step_open, recorded_child_run_id};
30
31/// Where a child of a wave stands before the wave runs.
32enum WaveSlot {
33 /// Completed in a previous execution: replayed as recorded.
34 Replayed {
35 step_id: Uuid,
36 outcome: SubWorkflowOutcome,
37 },
38 /// Left open by a child that suspended: the step is reused.
39 Open(Box<Step>),
40 /// Recorded now, re-entering the child run of a step interrupted by a
41 /// lost lease, if any.
42 New(Option<Uuid>),
43}
44
45/// A child of a wave once its step is recorded.
46enum WaveChild {
47 Replayed {
48 step_id: Uuid,
49 outcome: SubWorkflowOutcome,
50 },
51 Started {
52 step_id: Uuid,
53 config: WorkflowStepConfig,
54 start: Result<ChildStart, EngineError>,
55 },
56}
57
58/// A child of a wave once it ran.
59enum WaveResult {
60 Replayed {
61 step_id: Uuid,
62 outcome: SubWorkflowOutcome,
63 },
64 Ran {
65 step_id: Uuid,
66 config: Box<WorkflowStepConfig>,
67 run: ChildRun,
68 },
69}
70
71/// Reject a wave in which two children share a step name.
72///
73/// The step name identifies a child of the wave in the run timeline and on
74/// resume, where two children with one name would re-enter one child run.
75fn reject_duplicate_step_names(wave: &[(String, WorkflowStepConfig)]) -> Result<(), EngineError> {
76 let mut seen = HashSet::with_capacity(wave.len());
77 for (name, _) in wave {
78 if !seen.insert(name.as_str()) {
79 return Err(EngineError::StepConfig(format!(
80 "sub-workflow wave has two steps named {name:?}; give each child its own step name"
81 )));
82 }
83 }
84 Ok(())
85}
86
87impl WorkflowContext {
88 /// Execute a wave of sub-workflows of one handler, concurrently.
89 ///
90 /// Each entry starts one child run of `handler` with its input and its
91 /// [`WorkflowOptions`], as [`workflow_with`](Self::workflow_with) does:
92 /// step name, labels, concurrency key, priority and `allow_failure` are
93 /// set per child. Every child gets a `Workflow` step at the same
94 /// position, and the outcomes come back in the order of `children`.
95 ///
96 /// The child runs are created in list order, then executed concurrently;
97 /// the call returns once every child has finished or suspended. A
98 /// concurrency key already held, by an earlier sibling or by any other
99 /// active run, gives a [`SubWorkflowOutcome::Conflict`] for that entry,
100 /// replayed as-is on resume.
101 ///
102 /// The cost cap of the root run holds for the whole tree: every agent step
103 /// of every child checks it against what the root, the children and their
104 /// descendants have spent, plus the budget their running steps hold. A
105 /// wave never takes the root run past its cap; a child refused by the cap
106 /// ends `Cancelled` with `RUN_BUDGET_EXCEEDED`. The workflow guard counts
107 /// each child as one invocation.
108 ///
109 /// A child that suspends leaves its step open while its siblings run to
110 /// the end; the parent then suspends with it. On resume, completed steps
111 /// are replayed and suspended children are re-entered: no child run is
112 /// created twice. Each suspended child publishes its own event (approval
113 /// requested, signal awaited). An empty list records nothing.
114 ///
115 /// # Errors
116 ///
117 /// Before anything runs: [`EngineError::StepConfig`] when two entries share
118 /// a step name, [`EngineError::Serialization`] if an input cannot be
119 /// serialized, [`EngineError::InvalidWorkflow`] when `handler` is not
120 /// registered, and [`EngineError::WorkflowGuardRejected`] when the guard
121 /// refuses the wave (a wave that would cross `max_fan_out` is refused
122 /// whole). Returns [`EngineError::ReplayDivergence`] when the step recorded
123 /// under an entry's name has another kind.
124 ///
125 /// Once every child has settled and its step is recorded: the first child
126 /// failure in list order that `allow_failure` does not tolerate, otherwise
127 /// [`EngineError::ChildSuspended`] for the first suspended child in list
128 /// order.
129 ///
130 /// # Examples
131 ///
132 /// ```no_run
133 /// use ironflow_engine::config::WorkflowOptions;
134 /// use ironflow_engine::context::WorkflowContext;
135 /// use ironflow_engine::error::EngineError;
136 /// use ironflow_engine::executor::SubWorkflowOutcome;
137 /// use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
138 /// use serde::{Deserialize, Serialize};
139 ///
140 /// #[derive(Serialize, Deserialize)]
141 /// struct DelegateInput {
142 /// agent: String,
143 /// }
144 ///
145 /// struct Delegate;
146 ///
147 /// impl WorkflowHandler for Delegate {
148 /// fn name(&self) -> &str { "delegate" }
149 /// fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
150 /// Box::pin(async move { Ok(()) })
151 /// }
152 /// }
153 ///
154 /// impl TypedWorkflow for Delegate {
155 /// type Input = DelegateInput;
156 /// }
157 ///
158 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
159 /// let tour = 1;
160 /// let wave = ["alice", "bob"]
161 /// .into_iter()
162 /// .map(|agent| {
163 /// let options = WorkflowOptions::new()
164 /// .step_name(format!("delegate-{tour}-{agent}"))
165 /// .label("agent", agent)
166 /// .allow_failure();
167 /// (DelegateInput { agent: agent.to_string() }, options)
168 /// })
169 /// .collect();
170 /// for outcome in ctx.workflows_with(&Delegate, wave).await? {
171 /// if let SubWorkflowOutcome::Completed(child) = outcome {
172 /// println!("run {} ended {}", child.run_id(), child.status());
173 /// }
174 /// }
175 /// # Ok(())
176 /// # }
177 /// ```
178 pub async fn workflows_with<W: TypedWorkflow>(
179 &mut self,
180 handler: &W,
181 children: Vec<(W::Input, WorkflowOptions)>,
182 ) -> Result<Vec<SubWorkflowOutcome>, EngineError> {
183 let mut wave = Vec::with_capacity(children.len());
184 for (input, options) in children {
185 wave.push(options.into_step_config(handler.name(), to_value(&input)?));
186 }
187 reject_duplicate_step_names(&wave)?;
188 if wave.is_empty() {
189 return Ok(Vec::new());
190 }
191
192 // Plan mode: the wave is one parallel group, every child expanded.
193 if let Some(plan) = self.plan().cloned() {
194 return self.plan_sub_workflow_wave(&plan, handler, wave).await;
195 }
196 self.run_sub_workflow_wave(handler, wave).await
197 }
198
199 /// Record, then run, a wave of `Workflow` steps at one position.
200 async fn run_sub_workflow_wave(
201 &mut self,
202 handler: &dyn WorkflowHandler,
203 wave: Vec<(String, WorkflowStepConfig)>,
204 ) -> Result<Vec<SubWorkflowOutcome>, EngineError> {
205 let position = self.position;
206
207 // Replay first: a child whose step completed in a previous execution
208 // returns its recorded outcome and is not counted by the guard again.
209 let mut slots = Vec::with_capacity(wave.len());
210 for (step_name, _) in &wave {
211 slots.push(self.wave_slot(position, step_name)?);
212 }
213 let to_run = slots
214 .iter()
215 .filter(|slot| !matches!(slot, WaveSlot::Replayed { .. }))
216 .count();
217 if to_run == 0 {
218 self.position += 1;
219 let (step_ids, outcomes): (Vec<Uuid>, Vec<SubWorkflowOutcome>) = slots
220 .into_iter()
221 .filter_map(|slot| match slot {
222 WaveSlot::Replayed { step_id, outcome } => Some((step_id, outcome)),
223 WaveSlot::Open(_) | WaveSlot::New(_) => None,
224 })
225 .unzip();
226 self.last_step_ids = step_ids;
227 return Ok(outcomes);
228 }
229
230 let child_handler = self.resolve_child_handler(handler.name())?;
231 // A child runs on the worker that runs its parent: refuse it here
232 // rather than run it on a host missing what it requires.
233 self.check_child_worker_tags(handler)?;
234 // Every child of the wave is claimed at once, or none is.
235 if let (Some(guard_config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
236 let state = guard_state
237 .lock()
238 .map_err(|_| WorkflowRejection::GuardUnavailable)?;
239 let count = u32::try_from(to_run).unwrap_or(u32::MAX);
240 state.enter_wave(guard_config, handler.name(), count)?;
241 }
242 // Paused while it ran: no child is started, the run stops here.
243 self.check_not_paused().await?;
244 self.position += 1;
245
246 // The children share one live total of the tree, so the cap holds for
247 // the wave as a whole rather than for each child against a snapshot.
248 let shared_cost = match &self.shared_cost {
249 Some(shared) => shared.clone(),
250 None => SharedCost::new(self.charged_cost_usd()),
251 };
252
253 // Steps and child runs are created in list order, so a key held by an
254 // earlier sibling conflicts the same way on every execution.
255 let mut children = Vec::with_capacity(slots.len());
256 for ((step_name, config), slot) in wave.into_iter().zip(slots) {
257 let (step_id, resume) = match slot {
258 WaveSlot::Replayed { step_id, outcome } => {
259 children.push(WaveChild::Replayed { step_id, outcome });
260 continue;
261 }
262 WaveSlot::Open(step) => (
263 step.id,
264 recorded_child_run_id(&step).map(ChildResume::Suspended),
265 ),
266 WaveSlot::New(interrupted) => (
267 self.record_workflow_step(&step_name, &config, position)
268 .await?,
269 interrupted.map(ChildResume::Interrupted),
270 ),
271 };
272 let start = match self.branch_guard(handler.name()) {
273 Ok(guard_state) => {
274 self.prepare_child(
275 &config,
276 step_id,
277 resume,
278 child_handler.clone(),
279 guard_state,
280 Some(shared_cost.clone()),
281 )
282 .await
283 }
284 Err(err) => Err(err),
285 };
286 children.push(WaveChild::Started {
287 step_id,
288 config,
289 start,
290 });
291 }
292
293 let this = &*self;
294 let results = join_all(children.into_iter().map(|child| this.run_wave_child(child))).await;
295
296 // Steps are closed in list order: the first failure, otherwise the
297 // first suspension, is what the parent reports.
298 let mut outcomes = Vec::with_capacity(results.len());
299 let mut step_ids = Vec::with_capacity(results.len());
300 let mut failure = None;
301 let mut suspension = None;
302 let mut sibling_suspensions = Vec::new();
303 for result in results {
304 let (step_id, settled) = match result {
305 WaveResult::Replayed { step_id, outcome } => (step_id, Ok(outcome)),
306 WaveResult::Ran {
307 step_id,
308 config,
309 run,
310 } => {
311 if matches!(&run.result, Err(err) if err.is_suspension()) {
312 sibling_suspensions.extend(run.sibling_suspensions);
313 }
314 let settled = self.settle_child_step(step_id, &config, run.result).await;
315 (step_id, settled)
316 }
317 };
318 step_ids.push(step_id);
319 match settled {
320 Ok(outcome) => outcomes.push(outcome),
321 Err(err) if leaves_step_open(&err) => {
322 if suspension.is_none() {
323 suspension = Some(err);
324 } else if err.is_suspension() {
325 sibling_suspensions.push(err);
326 }
327 }
328 Err(err) => {
329 if failure.is_none() {
330 failure = Some(err);
331 }
332 }
333 }
334 }
335
336 if let Some(err) = failure {
337 return Err(err);
338 }
339 if let Some(err) = suspension {
340 self.sibling_suspensions.extend(sibling_suspensions);
341 return Err(err);
342 }
343 self.last_step_ids = step_ids;
344 Ok(outcomes)
345 }
346
347 /// Where the child named `step_name` stands at the wave `position`.
348 ///
349 /// # Errors
350 ///
351 /// Returns [`EngineError::ReplayDivergence`] when the step recorded under
352 /// that name is not a `Workflow` step.
353 fn wave_slot(&mut self, position: u32, step_name: &str) -> Result<WaveSlot, EngineError> {
354 let key = (position, step_name.to_string());
355 if let Some(step) = self.replay_wave_steps.get(&key).cloned() {
356 check_replay_identity(&step, position, step_name, &StepKind::Workflow)?;
357 match step.status.state {
358 StepStatus::Completed => {
359 let outcome = self.replay_recorded_child(&step)?;
360 return Ok(WaveSlot::Replayed {
361 step_id: step.id,
362 outcome,
363 });
364 }
365 StepStatus::Running => return Ok(WaveSlot::Open(Box::new(step))),
366 _ => {}
367 }
368 }
369 let interrupted = match self.interrupted_children.get(&key) {
370 Some(step) => {
371 check_replay_identity(step, position, step_name, &StepKind::Workflow)?;
372 recorded_child_run_id(step)
373 }
374 None => None,
375 };
376 Ok(WaveSlot::New(interrupted))
377 }
378
379 /// A guard state of its own for a child running concurrently with its
380 /// siblings, its invocation claimed with the wave: depth and call chain
381 /// per branch, invocations and tokens shared by the whole run tree.
382 fn branch_guard(&self, target: &str) -> Result<Option<SharedGuardState>, EngineError> {
383 let Some(guard_state) = &self.guard_state else {
384 return Ok(None);
385 };
386 let branch = guard_state
387 .lock()
388 .map_err(|_| WorkflowRejection::GuardUnavailable)?
389 .branch_into(target);
390 Ok(Some(Arc::new(Mutex::new(branch))))
391 }
392
393 /// Execute one child of a wave, unless it was replayed or settled at once.
394 async fn run_wave_child(&self, child: WaveChild) -> WaveResult {
395 match child {
396 WaveChild::Replayed { step_id, outcome } => WaveResult::Replayed { step_id, outcome },
397 WaveChild::Started {
398 step_id,
399 config,
400 start,
401 } => {
402 let run = match start {
403 Ok(ChildStart::Run(prepared)) => self.run_child(&config, *prepared).await,
404 Ok(ChildStart::Settled(outcome)) => ChildRun::from(Ok(outcome)),
405 Err(err) => ChildRun::from(Err(err)),
406 };
407 WaveResult::Ran {
408 step_id,
409 config: Box::new(config),
410 run,
411 }
412 }
413 }
414 }
415
416 /// Record a wave while planning: one parallel group, every child expanded
417 /// after its own step when the depth limit allows it.
418 async fn plan_sub_workflow_wave(
419 &mut self,
420 plan: &SharedPlanRecorder,
421 handler: &dyn WorkflowHandler,
422 wave: Vec<(String, WorkflowStepConfig)>,
423 ) -> Result<Vec<SubWorkflowOutcome>, EngineError> {
424 self.position += 1;
425 {
426 let mut recorder = lock_plan(plan);
427 let group = recorder.next_group();
428 for (step_name, _) in &wave {
429 recorder.record_or_stop(
430 step_name,
431 StepKind::Workflow,
432 &self.workflow_name,
433 Some(group.clone()),
434 )?;
435 }
436 }
437
438 // The steps after the wave depend on where every child ended.
439 let mut frontier = Vec::new();
440 let mut outcomes = Vec::with_capacity(wave.len());
441 for (step_name, config) in wave {
442 lock_plan(plan).set_last(vec![step_name]);
443 let planned = self
444 .expand_planned_child(plan, handler, config.payload)
445 .await;
446 frontier.extend(lock_plan(plan).last().iter().cloned());
447 outcomes.push(SubWorkflowOutcome::Completed(planned));
448 }
449 lock_plan(plan).set_last(frontier);
450 Ok(outcomes)
451 }
452}