Skip to main content

agentd/runtime/
nested.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Nested bodies**: `foreach`/`batch` (dynamic fan-out
3//! over an array, batches with bounded parallelism and rate pacing, per-batch
4//! durable progress, positional collection, `on_error: continue` slots),
5//! `iterate` (a bounded structured loop with `while`/`until`/
6//! `max_iterations`), `parallel` (static branches, fan-in object), `race`
7//! (first branch to finish wins, the rest are cancelled) and `subgraph` (an
8//! inline sub-DAG). Body steps are ordinary steps executed under a **scope**:
9//! their run-record ids are `<parent>[<index>].<step>` (elements /
10//! iterations), `<parent>{<branch>}.<step>` (branches) or `<parent>.<step>`
11//! (subgraph); templates inside a body see `item`, `index`, `batch`,
12//! `iteration`, `branch` and `steps.<sibling>` resolved within the scope.
13//! The parent step's `wait` record carries the durable progress.
14
15use super::reactor::Runtime;
16use crate::engine::model::{Body, OnError, Step, Workflow};
17use crate::engine::run::{RunState, StepStatus};
18use crate::engine::template::Data;
19use crate::state::now_ms;
20use serde_json::{Map, Value, json};
21use std::collections::BTreeMap;
22
23/// The scope a nested step runs in.
24#[derive(Debug, Clone, Default)]
25pub struct Scope {
26    /// The scoped parent id (`each[3]`, `par{a}`, `loop[2]`, `sub`).
27    pub parent: String,
28    /// The parent step's definition (for on_error, siblings…).
29    pub parent_step: Option<Step>,
30    pub siblings: BTreeMap<String, Step>,
31    pub item: Option<Value>,
32    pub index: Option<usize>,
33    pub batch: Option<usize>,
34    pub iteration: Option<usize>,
35    pub branch: Option<String>,
36}
37
38/// One segment of a scoped id: `name`, `name[i]`, `name{branch}`.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Segment {
41    pub name: String,
42    pub index: Option<usize>,
43    pub branch: Option<String>,
44}
45
46/// Parse `each[3].classify` → `[each[3], classify]`.
47pub fn parse_scoped(id: &str) -> Vec<Segment> {
48    id.split('.')
49        .map(|seg| {
50            if let Some((name, rest)) = seg.split_once('[') {
51                Segment {
52                    name: name.to_string(),
53                    index: rest.trim_end_matches(']').parse().ok(),
54                    branch: None,
55                }
56            } else if let Some((name, rest)) = seg.split_once('{') {
57                Segment {
58                    name: name.to_string(),
59                    branch: Some(rest.trim_end_matches('}').to_string()),
60                    index: None,
61                }
62            } else {
63                Segment {
64                    name: seg.to_string(),
65                    index: None,
66                    branch: None,
67                }
68            }
69        })
70        .collect()
71}
72
73/// The scoped id of a body step.
74pub fn scoped_id(parent: &str, step: &str) -> String {
75    format!("{parent}.{step}")
76}
77
78/// Whether an id is nested (has a scope).
79pub fn is_scoped(id: &str) -> bool {
80    id.contains('.')
81}
82
83/// The parent scope of a scoped id (`each[3].classify` → `each[3]`).
84pub fn parent_of(id: &str) -> Option<&str> {
85    id.rsplit_once('.').map(|(p, _)| p)
86}
87
88impl Runtime {
89    /// Resolve a (possibly scoped) step id against the workflow: the step
90    /// definition and its scope (`None` for a top-level step).
91    pub(crate) fn resolve_step(
92        &self,
93        wf: &Workflow,
94        run: &RunState,
95        id: &str,
96    ) -> Option<(Step, Option<Scope>)> {
97        let segs = parse_scoped(id);
98        if segs.len() == 1 && segs[0].index.is_none() && segs[0].branch.is_none() {
99            return wf.step(id).cloned().map(|s| (s, None));
100        }
101        // Walk the chain: the first segment is a top-level step; each further
102        // segment is a body/branch step of the previous.
103        let mut current: Step = wf.step(&segs[0].name)?.clone();
104        let mut scope = Scope::default();
105        let mut parent_path = String::new();
106        for (i, seg) in segs.iter().enumerate() {
107            let is_last = i + 1 == segs.len();
108            let this_path = if parent_path.is_empty() {
109                seg_label(seg)
110            } else {
111                format!("{parent_path}.{}", seg_label(seg))
112            };
113            if is_last {
114                break;
115            }
116            // Descend into `current`'s body/branch for the next segment.
117            let body: &Body = match &seg.branch {
118                Some(b) => current.branches.get(b)?,
119                None => current.body.as_ref()?,
120            };
121            let next = &segs[i + 1];
122            let child = body.steps.get(&next.name)?.clone();
123            // The parent's progress lives under the parent's own (unsuffixed) id.
124            let progress = run
125                .steps
126                .get(&strip_scope_suffix(&this_path))
127                .and_then(|s| s.wait.clone())
128                .unwrap_or(Value::Null);
129            let item = seg.index.and_then(|ix| {
130                element_item(&progress, ix).or_else(|| self.items_of(&progress).get(ix).cloned())
131            });
132            scope = Scope {
133                parent: this_path.clone(),
134                parent_step: Some(current.clone()),
135                siblings: body.steps.clone(),
136                item,
137                index: seg.index,
138                batch: seg.index.and_then(|ix| batch_of(&progress, ix)),
139                iteration: if current.kind == "iterate" {
140                    seg.index
141                } else {
142                    None
143                },
144                branch: seg.branch.clone(),
145            };
146            current = child;
147            parent_path = this_path;
148        }
149        Some((current, Some(scope)))
150    }
151
152    /// The template data of a scoped step: the run data + scope variables +
153    /// `steps.<sibling>` overlaid with the scope's instances.
154    pub(crate) fn scoped_data(&mut self, run_id: &str, scope: &Scope) -> Data {
155        let mut data = self.run_data(run_id);
156        if let Some(it) = &scope.item {
157            data.insert("item".into(), it.clone());
158            if let Some(alias) = scope.parent_step.as_ref().and_then(|p| p.field_str("as"))
159                && alias != "item"
160            {
161                data.insert(alias.to_string(), it.clone());
162            }
163        }
164        if let Some(ix) = scope.index {
165            data.insert("index".into(), json!(ix));
166        }
167        if let Some(b) = scope.batch {
168            data.insert("batch".into(), json!(b));
169        }
170        if let Some(k) = scope.iteration {
171            data.insert("iteration".into(), json!(k));
172        }
173        if let Some(b) = &scope.branch {
174            data.insert("branch".into(), json!(b));
175        }
176        if let Some(run) = self.runs.get(run_id) {
177            let mut steps = data
178                .get("steps")
179                .and_then(Value::as_object)
180                .cloned()
181                .unwrap_or_default();
182            for sib in scope.siblings.keys() {
183                let sid = scoped_id(&scope.parent, sib);
184                if let Some(st) = run.steps.get(&sid) {
185                    steps.insert(sib.clone(), json!({"status": st.status, "output": st.output, "error": st.error, "attempt": st.attempt}));
186                }
187            }
188            data.insert("steps".into(), Value::Object(steps));
189        }
190        data
191    }
192
193    // ---- start ---------------------------------------------------------------
194
195    /// Start a nested step (`foreach`/`batch`/`iterate`/`parallel`/`race`/`subgraph`).
196    pub(crate) fn nested_start(
197        &mut self,
198        run_id: &str,
199        step_id: &str,
200        step: &Step,
201        spec: &Map<String, Value>,
202    ) {
203        let progress = match step.kind.as_str() {
204            "foreach" | "batch" => {
205                let over = spec.get("over").cloned().unwrap_or(Value::Null);
206                let items = match over {
207                    Value::Array(a) => a,
208                    Value::Null => Vec::new(),
209                    Value::Object(o) => o
210                        .into_iter()
211                        .map(|(k, v)| json!({"key": k, "value": v}))
212                        .collect(),
213                    other => {
214                        self.finish_step_pub(
215                            run_id,
216                            step_id,
217                            StepStatus::Failed,
218                            None,
219                            Some(format!(
220                                "{}: over must be an array (got {})",
221                                step.kind, other
222                            )),
223                            0,
224                        );
225                        return;
226                    }
227                };
228                let batch = spec.get("batch").cloned().unwrap_or(json!({}));
229                // The ceiling is operator policy (`limits.workflow.fan_out`);
230                // a definition asking for more was already refused at load, so
231                // this clamp only ever bounds the DEFAULT.
232                let fan_out_cap = self
233                    .settings
234                    .limits
235                    .workflow
236                    .fan_out
237                    .map(u64::from)
238                    .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL);
239                let size = spec
240                    .get("size")
241                    .and_then(Value::as_u64)
242                    .or_else(|| batch.get("size").and_then(Value::as_u64))
243                    .unwrap_or(if step.kind == "batch" { 10 } else { 1 })
244                    .max(1) as usize;
245                let parallel = spec
246                    .get("parallel")
247                    .and_then(Value::as_u64)
248                    .or_else(|| batch.get("parallel").and_then(Value::as_u64))
249                    // Default to real concurrency. A fan-out that runs one at a
250                    // time is a loop with extra syntax, and it was the default
251                    // purely because the ceiling was low — the ceiling now lives
252                    // in `limits.workflow.fan_out` and over-asking is refused at
253                    // load, so the default can be what people expect.
254                    .unwrap_or(crate::engine::model::DEFAULT_FAN_OUT)
255                    .clamp(1, fan_out_cap) as usize;
256                let rate = spec
257                    .get("rate")
258                    .and_then(Value::as_str)
259                    .or_else(|| batch.get("rate").and_then(Value::as_str))
260                    .map(str::to_string);
261                let group_by = spec.get("by").and_then(Value::as_str).map(str::to_string);
262                // `batch.by`: group elements by a key into batches (order of first appearance).
263                let items = match (&group_by, step.kind.as_str()) {
264                    (Some(key), "batch") => {
265                        let mut groups: Vec<(Value, Vec<Value>)> = Vec::new();
266                        for it in items {
267                            let k = crate::engine::data::path_of(&it, key).unwrap_or(Value::Null);
268                            match groups.iter_mut().find(|(gk, _)| *gk == k) {
269                                Some((_, g)) => g.push(it),
270                                None => groups.push((k, vec![it])),
271                            }
272                        }
273                        groups.into_iter().map(|(_, g)| Value::Array(g)).collect()
274                    }
275                    _ => items,
276                };
277                let total = items.len();
278                let items_ref = self.store_items(run_id, step_id, items);
279                json!({
280                    "kind": step.kind, "total": total, "size": size, "parallel": parallel, "rate": rate,
281                    "cursor": 0, "active": [], "results": {}, "done": 0, "batches_done": 0, "next_batch_at": 0,
282                    "items": items_ref, "started_ms": now_ms(),
283                    "collect": spec.get("collect").cloned().unwrap_or(Value::Null),
284                    "as": spec.get("as").and_then(Value::as_str).unwrap_or("item"),
285                })
286            }
287            "iterate" => {
288                let max = spec
289                    .get("max_iterations")
290                    .and_then(Value::as_u64)
291                    .unwrap_or(crate::engine::model::MAX_ITERATIONS)
292                    .min(crate::engine::model::MAX_ITERATIONS);
293                json!({"kind": "iterate", "iteration": 0, "max": max, "results": [], "collect": spec.get("collect").cloned().unwrap_or(Value::Null), "started_ms": now_ms()})
294            }
295            "parallel" | "race" => {
296                let branches: Vec<String> = step.branches.keys().cloned().collect();
297                // `timeout` is a COMMON_FIELD: `parse_step` lifts it into the
298                // typed `step.timeout_ms` and never copies it into `spec`, so
299                // reading it back out of `spec` left `timeout_ms` permanently
300                // null and a `race` deadline silently never fired.
301                json!({"kind": step.kind, "branches": branches, "results": {}, "errors": {}, "started_ms": now_ms(), "min_success": spec.get("min_success").and_then(Value::as_u64), "timeout_ms": step.timeout_ms})
302            }
303            "subgraph" => json!({"kind": "subgraph", "started_ms": now_ms()}),
304            other => {
305                self.finish_step_pub(
306                    run_id,
307                    step_id,
308                    StepStatus::Failed,
309                    None,
310                    Some(format!("{other} is not a nested kind")),
311                    0,
312                );
313                return;
314            }
315        };
316        if let Some(st) = self
317            .runs
318            .get_mut(run_id)
319            .and_then(|r| r.steps.get_mut(step_id))
320        {
321            st.status = StepStatus::Running;
322            st.wait = Some(progress);
323        }
324        if let Some(r) = self.runs.get_mut(run_id) {
325            r.touch();
326        }
327        self.checkpoint(false);
328        self.nested_advance(run_id, step_id);
329    }
330
331    /// Store a foreach's items inline or as an artifact (`limits.inline_max_bytes`).
332    fn store_items(&mut self, run_id: &str, step_id: &str, items: Vec<Value>) -> Value {
333        let v = Value::Array(items);
334        let cap = self.settings.limits.inline_max_bytes.unwrap_or(65_536) as usize;
335        if v.to_string().len() > cap {
336            match self.artifacts.create(
337                &self.durable,
338                super::artifacts::NewArtifact {
339                    name: format!("{run_id}/{step_id}/items.json").as_str(),
340                    mime: Some("application/json"),
341                    content: v.clone(),
342                    created_by: Some("engine"),
343                    sensitive: false,
344                    owner: Some(run_id),
345                },
346            ) {
347                Ok(meta) => return json!({"$artifact": meta["id"]}),
348                Err(e) => self.log.warn(
349                    "nested.items.artifact_fail",
350                    json!({"run": run_id, "step": step_id, "err": e}),
351                ),
352            }
353        }
354        v
355    }
356
357    /// The items array of a foreach progress record (dereferencing an artifact).
358    fn items_of(&self, progress: &Value) -> Vec<Value> {
359        match &progress["items"] {
360            Value::Array(a) => a.clone(),
361            Value::Object(o) if o.get("$artifact").is_some() => o
362                .get("$artifact")
363                .and_then(Value::as_str)
364                .and_then(|id| self.artifacts.get(id))
365                .and_then(|a| a.content.as_array().cloned())
366                .unwrap_or_default(),
367            _ => Vec::new(),
368        }
369    }
370
371    // ---- advance -------------------------------------------------------------
372
373    /// Drive a nested step: schedule its body steps, start new elements /
374    /// iterations, detect completion. Idempotent; called after any scoped
375    /// step finishes and every scheduling tick.
376    pub(crate) fn nested_advance(&mut self, run_id: &str, parent_id: &str) {
377        let Some(wf) = self.definition_for_run(run_id) else {
378            return;
379        };
380        let Some(run) = self.runs.get(run_id) else {
381            return;
382        };
383        if run.status.is_terminal() {
384            return;
385        }
386        let Some((parent, _)) = self.resolve_step(&wf, run, parent_id) else {
387            return;
388        };
389        let Some(progress) = run.steps.get(parent_id).and_then(|s| s.wait.clone()) else {
390            return;
391        };
392        if run.steps.get(parent_id).map(|s| s.status) != Some(StepStatus::Running) {
393            return;
394        }
395        match progress["kind"].as_str() {
396            Some("foreach") | Some("batch") => {
397                self.advance_foreach(run_id, parent_id, &parent, progress)
398            }
399            Some("iterate") => self.advance_iterate(run_id, parent_id, &parent, progress),
400            Some("parallel") => self.advance_parallel(run_id, parent_id, &parent, progress, false),
401            Some("race") => self.advance_parallel(run_id, parent_id, &parent, progress, true),
402            Some("subgraph") => self.advance_subgraph(run_id, parent_id, &parent),
403            _ => {}
404        }
405    }
406
407    /// Schedule one body instance under `scope_id`: returns `(done, failed
408    /// error)` — done when every body step is terminal.
409    fn drive_body(
410        &mut self,
411        run_id: &str,
412        scope_id: &str,
413        body: &Body,
414        parent: &Step,
415    ) -> BodyState {
416        let ids: Vec<String> = body.topo_order();
417        // Ensure the instances exist.
418        if let Some(run) = self.runs.get_mut(run_id) {
419            for id in &ids {
420                run.steps.entry(scoped_id(scope_id, id)).or_default();
421            }
422        }
423        let mut all_terminal = true;
424        let mut failed: Option<String> = None;
425        let mut ready: Vec<String> = Vec::new();
426        let mut in_flight = false;
427        // A fixpoint over `when` skips.
428        let mut changed = true;
429        while changed {
430            changed = false;
431            let Some(snapshot) = self.runs.get(run_id).map(|r| r.steps.clone()) else {
432                return BodyState::Waiting;
433            };
434            for id in &ids {
435                let sid = scoped_id(scope_id, id);
436                let st = snapshot.get(&sid).cloned().unwrap_or_default();
437                match st.status {
438                    StepStatus::Running | StepStatus::Suspended => {
439                        in_flight = true;
440                        all_terminal = false;
441                        continue;
442                    }
443                    StepStatus::Failed | StepStatus::Timeout | StepStatus::Cancelled => {
444                        if failed.is_none() {
445                            failed = Some(
446                                st.error
447                                    .clone()
448                                    .unwrap_or_else(|| format!("{id} {}", st.status.as_label())),
449                            );
450                        }
451                        continue;
452                    }
453                    StepStatus::Done | StepStatus::Skipped | StepStatus::Pruned => continue,
454                    StepStatus::Pending => {}
455                }
456                all_terminal = false;
457                if ready.contains(&sid) {
458                    continue;
459                }
460                let step = &body.steps[id];
461                if st.forced {
462                    ready.push(sid);
463                    continue;
464                }
465                let deps_ok = step.depends_on.iter().all(|d| {
466                    snapshot
467                        .get(&scoped_id(scope_id, d))
468                        .is_some_and(|s| s.status.is_satisfied())
469                });
470                let deps_failed = step.depends_on.iter().any(|d| {
471                    snapshot.get(&scoped_id(scope_id, d)).is_some_and(|s| {
472                        matches!(
473                            s.status,
474                            StepStatus::Failed | StepStatus::Cancelled | StepStatus::Timeout
475                        )
476                    })
477                });
478                if deps_failed {
479                    // Cascade: an unrouted failed dependency cancels dependents.
480                    continue;
481                }
482                if !deps_ok {
483                    continue;
484                }
485                if let Some(w) = &step.when {
486                    let scope = {
487                        let wf = self
488                            .definition_for_run(run_id)
489                            .unwrap_or_else(unreachable_wf);
490                        self.runs
491                            .get(run_id)
492                            .and_then(|r| self.resolve_step(&wf, r, &sid))
493                            .and_then(|(_, s)| s)
494                            .unwrap_or_default()
495                    };
496                    let data = self.scoped_data_view(run_id, &scope);
497                    let expr = w.trim().trim_start_matches("CEL:").trim();
498                    let vars: Vec<(&str, &Value)> =
499                        data.iter().map(|(k, v)| (k.as_str(), v)).collect();
500                    match crate::cel::eval_bool(expr, &vars) {
501                        Ok(true) => {}
502                        Ok(false) => {
503                            if let Some(r) = self.runs.get_mut(run_id) {
504                                r.end_step(&sid, StepStatus::Skipped, None, None);
505                            }
506                            changed = true;
507                            continue;
508                        }
509                        Err(e) => {
510                            failed = Some(format!("{id}: when: {e}"));
511                            continue;
512                        }
513                    }
514                }
515                ready.push(sid);
516            }
517        }
518        if let Some(e) = failed {
519            // A failed body step that was not routed: the instance failed —
520            // unless something is still running (let it finish first).
521            if !in_flight {
522                return BodyState::Failed(e);
523            }
524            return BodyState::Waiting;
525        }
526        if all_terminal {
527            return BodyState::Done;
528        }
529        for sid in ready {
530            // Stop if the run or the parent step ended meanwhile (a sibling's
531            // failure may have failed the parent).
532            if !self.parent_running(run_id, &strip_scope_suffix(scope_id)) {
533                break;
534            }
535            self.execute_step_pub(run_id, &sid);
536        }
537        let _ = parent;
538        BodyState::Waiting
539    }
540
541    /// Whether the run is live and the parent step still running.
542    fn parent_running(&self, run_id: &str, parent_id: &str) -> bool {
543        self.runs.get(run_id).is_some_and(|r| {
544            !r.status.is_terminal()
545                && r.steps
546                    .get(parent_id)
547                    .is_some_and(|st| st.status == StepStatus::Running)
548        })
549    }
550
551    /// Read-only scoped data (no memory read-through side effects needed here).
552    fn scoped_data_view(&mut self, run_id: &str, scope: &Scope) -> Data {
553        self.scoped_data(run_id, scope)
554    }
555
556    /// The result of a finished body instance: one sink ⇒ its output; many ⇒
557    /// `{sink: output}`.
558    fn body_result(&self, run_id: &str, scope_id: &str, body: &Body) -> Value {
559        let Some(run) = self.runs.get(run_id) else {
560            return Value::Null;
561        };
562        let sinks = body.sinks();
563        if sinks.len() == 1 {
564            return run
565                .steps
566                .get(&scoped_id(scope_id, &sinks[0]))
567                .and_then(|s| s.output.clone())
568                .unwrap_or(Value::Null);
569        }
570        let mut o = Map::new();
571        for s in sinks {
572            o.insert(
573                s.clone(),
574                run.steps
575                    .get(&scoped_id(scope_id, &s))
576                    .and_then(|st| st.output.clone())
577                    .unwrap_or(Value::Null),
578            );
579        }
580        Value::Object(o)
581    }
582
583    fn advance_foreach(
584        &mut self,
585        run_id: &str,
586        parent_id: &str,
587        parent: &Step,
588        mut progress: Value,
589    ) {
590        let Some(body) = parent.body.clone() else {
591            return;
592        };
593        let total = progress["total"].as_u64().unwrap_or(0) as usize;
594        let size = progress["size"].as_u64().unwrap_or(1).max(1) as usize;
595        let parallel = progress["parallel"].as_u64().unwrap_or(1).max(1) as usize;
596        let items = self.items_of(&progress);
597        let mut active: Vec<usize> = progress["active"]
598            .as_array()
599            .map(|a| {
600                a.iter()
601                    .filter_map(Value::as_u64)
602                    .map(|x| x as usize)
603                    .collect()
604            })
605            .unwrap_or_default();
606        let mut cursor = progress["cursor"].as_u64().unwrap_or(0) as usize;
607        let mut done = progress["done"].as_u64().unwrap_or(0) as usize;
608        let mut batches_done = progress["batches_done"].as_u64().unwrap_or(0) as usize;
609        let mut results = progress["results"].as_object().cloned().unwrap_or_default();
610        let mut changed = false;
611        // 1. Drive the active elements.
612        for ix in active.clone() {
613            if !self.parent_running(run_id, parent_id) {
614                return;
615            }
616            let scope_id = format!("{parent_id}[{ix}]");
617            match self.drive_body(run_id, &scope_id, &body, parent) {
618                BodyState::Waiting => {}
619                BodyState::Done => {
620                    let out = self.body_result(run_id, &scope_id, &body);
621                    results.insert(ix.to_string(), out);
622                    active.retain(|a| *a != ix);
623                    done += 1;
624                    changed = true;
625                }
626                BodyState::Failed(e) => {
627                    active.retain(|a| *a != ix);
628                    done += 1;
629                    changed = true;
630                    match parent.on_error {
631                        OnError::Continue | OnError::Goto(_) => {
632                            results.insert(ix.to_string(), json!({"index": ix, "error": e}));
633                        }
634                        OnError::Fail => {
635                            // The whole step fails; cancel the other active elements.
636                            self.cancel_scoped_children(run_id, parent_id);
637                            self.finish_step_pub(
638                                run_id,
639                                parent_id,
640                                StepStatus::Failed,
641                                Some(collect_results(&results, total)),
642                                Some(format!("element {ix} failed: {e}")),
643                                0,
644                            );
645                            return;
646                        }
647                    }
648                }
649            }
650        }
651        // 2. Batch bookkeeping: a batch is done when all its elements are.
652        let batches_total = total.div_ceil(size).max(if total == 0 { 0 } else { 1 });
653        while batches_done < batches_total {
654            let (from, to) = (batches_done * size, ((batches_done + 1) * size).min(total));
655            if (from..to).all(|i| results.contains_key(&i.to_string())) {
656                batches_done += 1;
657                changed = true;
658                // A completed batch is a DURABILITY POINT ("a restart resumes
659                // at the next batch"). Body steps are commonly pure and no
660                // longer checkpoint per step, so the boundary persists here —
661                // one write per batch instead of one per element.
662                self.checkpoint(false);
663                crate::state::kill_point("batch.k");
664            } else {
665                break;
666            }
667        }
668        // 3. Start new batches (bounded by `parallel`, paced by `rate`).
669        let rate = progress["rate"].as_str().map(super::subagents::parse_rate);
670        let mut next_batch_at = progress["next_batch_at"].as_u64().unwrap_or(0);
671        while cursor < total {
672            let batches_in_flight = active
673                .iter()
674                .map(|i| i / size)
675                .collect::<std::collections::BTreeSet<_>>()
676                .len();
677            if batches_in_flight >= parallel {
678                break;
679            }
680            if rate.is_some() && now_ms() < next_batch_at {
681                break;
682            }
683            let end = (cursor + size).min(total);
684            for ix in cursor..end {
685                active.push(ix);
686            }
687            if let Some((_, per_sec)) = rate {
688                next_batch_at = now_ms() + (1000.0 / per_sec.max(0.001)) as u64;
689            }
690            cursor = end;
691            changed = true;
692        }
693        // Save progress before executing (durable per batch).
694        progress["active"] = json!(active);
695        progress["cursor"] = json!(cursor);
696        progress["done"] = json!(done);
697        progress["batches_done"] = json!(batches_done);
698        progress["results"] = Value::Object(results.clone());
699        progress["next_batch_at"] = json!(next_batch_at);
700        if let Some(st) = self
701            .runs
702            .get_mut(run_id)
703            .and_then(|r| r.steps.get_mut(parent_id))
704        {
705            st.wait = Some(progress.clone());
706        }
707        if changed {
708            if let Some(r) = self.runs.get_mut(run_id) {
709                r.touch();
710            }
711            self.checkpoint(false);
712        }
713        // 4. Start the body of newly-active elements (their scoped states are fresh).
714        for ix in active.clone() {
715            if !self.parent_running(run_id, parent_id) {
716                return;
717            }
718            let scope_id = format!("{parent_id}[{ix}]");
719            let fresh = self.runs.get(run_id).is_some_and(|r| {
720                !body
721                    .steps
722                    .keys()
723                    .any(|k| r.steps.contains_key(&scoped_id(&scope_id, k)))
724            });
725            if fresh {
726                let _ = items.get(ix); // the item is read through the progress record by resolve_step
727                let _ = self.drive_body(run_id, &scope_id, &body, parent);
728            }
729        }
730        // 5. Completion.
731        if !self.parent_running(run_id, parent_id) {
732            return;
733        }
734        if done >= total && cursor >= total {
735            let out = collect_results(&results, total);
736            self.apply_collect(run_id, &progress["collect"], &out);
737            self.finish_step_pub(run_id, parent_id, StepStatus::Done, Some(out), None, 0);
738        }
739    }
740
741    fn advance_iterate(
742        &mut self,
743        run_id: &str,
744        parent_id: &str,
745        parent: &Step,
746        mut progress: Value,
747    ) {
748        let Some(body) = parent.body.clone() else {
749            return;
750        };
751        let k = progress["iteration"].as_u64().unwrap_or(0) as usize;
752        let max = progress["max"].as_u64().unwrap_or(1) as usize;
753        let mut results: Vec<Value> = progress["results"].as_array().cloned().unwrap_or_default();
754        let scope_id = format!("{parent_id}[{k}]");
755        let started = self.runs.get(run_id).is_some_and(|r| {
756            body.steps
757                .keys()
758                .any(|s| r.steps.contains_key(&scoped_id(&scope_id, s)))
759        });
760        if !started {
761            // `while` guard before iteration k.
762            if let Some(w) = parent.field_str("while") {
763                let scope = Scope {
764                    parent: scope_id.clone(),
765                    parent_step: Some(parent.clone()),
766                    siblings: body.steps.clone(),
767                    iteration: Some(k),
768                    ..Default::default()
769                };
770                let mut data = self.scoped_data(run_id, &scope);
771                data.insert("results".into(), Value::Array(results.clone()));
772                data.insert(
773                    "last".into(),
774                    results.last().cloned().unwrap_or(Value::Null),
775                );
776                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
777                match crate::cel::eval_bool(w.trim().trim_start_matches("CEL:").trim(), &vars) {
778                    Ok(true) => {}
779                    Ok(false) => {
780                        let out = iterate_output(&progress, &results);
781                        self.apply_collect(run_id, &progress["collect"], &out);
782                        self.finish_step_pub(
783                            run_id,
784                            parent_id,
785                            StepStatus::Done,
786                            Some(out),
787                            None,
788                            0,
789                        );
790                        return;
791                    }
792                    Err(e) => {
793                        self.finish_step_pub(
794                            run_id,
795                            parent_id,
796                            StepStatus::Failed,
797                            None,
798                            Some(format!("iterate.while: {e}")),
799                            0,
800                        );
801                        return;
802                    }
803                }
804            }
805            if k >= max {
806                let out = iterate_output(&progress, &results);
807                self.apply_collect(run_id, &progress["collect"], &out);
808                self.finish_step_pub(run_id, parent_id, StepStatus::Done, Some(out), None, 0);
809                return;
810            }
811        }
812        match self.drive_body(run_id, &scope_id, &body, parent) {
813            BodyState::Waiting => {}
814            BodyState::Failed(e) => {
815                self.finish_step_pub(
816                    run_id,
817                    parent_id,
818                    StepStatus::Failed,
819                    Some(iterate_output(&progress, &results)),
820                    Some(format!("iteration {k} failed: {e}")),
821                    0,
822                );
823            }
824            BodyState::Done => {
825                let out = self.body_result(run_id, &scope_id, &body);
826                results.push(out.clone());
827                // `until` after iteration k.
828                let mut stop = k + 1 >= max;
829                if let Some(u) = parent.field_str("until")
830                    && !stop
831                {
832                    let scope = Scope {
833                        parent: scope_id.clone(),
834                        parent_step: Some(parent.clone()),
835                        siblings: body.steps.clone(),
836                        iteration: Some(k),
837                        ..Default::default()
838                    };
839                    let mut data = self.scoped_data(run_id, &scope);
840                    data.insert("result".into(), out.clone());
841                    data.insert("results".into(), Value::Array(results.clone()));
842                    let vars: Vec<(&str, &Value)> =
843                        data.iter().map(|(k, v)| (k.as_str(), v)).collect();
844                    match crate::cel::eval_bool(u.trim().trim_start_matches("CEL:").trim(), &vars) {
845                        Ok(b) => stop = b,
846                        Err(e) => {
847                            self.finish_step_pub(
848                                run_id,
849                                parent_id,
850                                StepStatus::Failed,
851                                None,
852                                Some(format!("iterate.until: {e}")),
853                                0,
854                            );
855                            return;
856                        }
857                    }
858                }
859                progress["results"] = Value::Array(results.clone());
860                progress["iteration"] = json!(k + 1);
861                if let Some(st) = self
862                    .runs
863                    .get_mut(run_id)
864                    .and_then(|r| r.steps.get_mut(parent_id))
865                {
866                    st.wait = Some(progress.clone());
867                }
868                if let Some(r) = self.runs.get_mut(run_id) {
869                    r.touch();
870                }
871                self.checkpoint(false);
872                if stop {
873                    let out = iterate_output(&progress, &results);
874                    self.apply_collect(run_id, &progress["collect"], &out);
875                    self.finish_step_pub(run_id, parent_id, StepStatus::Done, Some(out), None, 0);
876                } else {
877                    // Next iteration on the next advance (this tick).
878                    self.nested_advance(run_id, parent_id);
879                }
880            }
881        }
882    }
883
884    fn advance_parallel(
885        &mut self,
886        run_id: &str,
887        parent_id: &str,
888        parent: &Step,
889        mut progress: Value,
890        race: bool,
891    ) {
892        let mut results = progress["results"].as_object().cloned().unwrap_or_default();
893        let mut errors = progress["errors"].as_object().cloned().unwrap_or_default();
894        let branches: Vec<String> = parent.branches.keys().cloned().collect();
895        let mut changed = false;
896        for b in &branches {
897            if results.contains_key(b) || errors.contains_key(b) {
898                continue;
899            }
900            if !self.parent_running(run_id, parent_id) {
901                return;
902            }
903            let Some(body) = parent.branches.get(b).cloned() else {
904                continue;
905            };
906            let scope_id = format!("{parent_id}{{{b}}}");
907            match self.drive_body(run_id, &scope_id, &body, parent) {
908                BodyState::Waiting => {}
909                BodyState::Done => {
910                    results.insert(b.clone(), self.body_result(run_id, &scope_id, &body));
911                    changed = true;
912                    if race {
913                        // First finisher wins: cancel the others.
914                        for other in &branches {
915                            if other != b {
916                                self.cancel_scoped_children(
917                                    run_id,
918                                    &format!("{parent_id}{{{other}}}"),
919                                );
920                            }
921                        }
922                        let winner = results.get(b).cloned().unwrap_or(Value::Null);
923                        self.finish_step_pub(
924                            run_id,
925                            parent_id,
926                            StepStatus::Done,
927                            Some(json!({"winner": b, "output": winner})),
928                            None,
929                            0,
930                        );
931                        return;
932                    }
933                }
934                BodyState::Failed(e) => {
935                    errors.insert(b.clone(), Value::String(e.clone()));
936                    changed = true;
937                    if !race && parent.on_error == OnError::Fail {
938                        self.cancel_scoped_children(run_id, parent_id);
939                        self.finish_step_pub(
940                            run_id,
941                            parent_id,
942                            StepStatus::Failed,
943                            Some(Value::Object(results.clone())),
944                            Some(format!("branch {b} failed: {e}")),
945                            0,
946                        );
947                        return;
948                    }
949                }
950            }
951        }
952        progress["results"] = Value::Object(results.clone());
953        progress["errors"] = Value::Object(errors.clone());
954        if let Some(st) = self
955            .runs
956            .get_mut(run_id)
957            .and_then(|r| r.steps.get_mut(parent_id))
958        {
959            st.wait = Some(progress.clone());
960        }
961        if changed {
962            if let Some(r) = self.runs.get_mut(run_id) {
963                r.touch();
964            }
965            self.checkpoint(false);
966        }
967        // Race timeout.
968        if race
969            && let Some(t) = progress["timeout_ms"].as_u64()
970            && now_ms() >= progress["started_ms"].as_u64().unwrap_or(0) + t
971        {
972            self.cancel_scoped_children(run_id, parent_id);
973            self.finish_step_pub(
974                run_id,
975                parent_id,
976                StepStatus::Timeout,
977                None,
978                Some("race: no branch finished in time".into()),
979                0,
980            );
981            return;
982        }
983        if results.len() + errors.len() >= branches.len() {
984            if race {
985                // Every branch failed.
986                self.finish_step_pub(
987                    run_id,
988                    parent_id,
989                    StepStatus::Failed,
990                    Some(Value::Object(results)),
991                    Some(format!(
992                        "race: every branch failed: {}",
993                        Value::Object(errors)
994                    )),
995                    0,
996                );
997            } else {
998                let min = progress["min_success"].as_u64().map(|m| m as usize);
999                let ok = min.is_none_or(|m| results.len() >= m)
1000                    && (errors.is_empty() || parent.on_error != OnError::Fail);
1001                let mut out = Value::Object(results.clone());
1002                if !errors.is_empty() {
1003                    out["_errors"] = Value::Object(errors.clone());
1004                }
1005                if ok {
1006                    self.finish_step_pub(run_id, parent_id, StepStatus::Done, Some(out), None, 0);
1007                } else {
1008                    self.finish_step_pub(
1009                        run_id,
1010                        parent_id,
1011                        StepStatus::Failed,
1012                        Some(out),
1013                        Some("parallel: not enough branches succeeded".into()),
1014                        0,
1015                    );
1016                }
1017            }
1018        }
1019    }
1020
1021    fn advance_subgraph(&mut self, run_id: &str, parent_id: &str, parent: &Step) {
1022        let Some(body) = parent.body.clone() else {
1023            return;
1024        };
1025        match self.drive_body(run_id, parent_id, &body, parent) {
1026            BodyState::Waiting => {}
1027            BodyState::Done => {
1028                let out = self.body_result(run_id, parent_id, &body);
1029                self.finish_step_pub(run_id, parent_id, StepStatus::Done, Some(out), None, 0);
1030            }
1031            BodyState::Failed(e) => self.finish_step_pub(
1032                run_id,
1033                parent_id,
1034                StepStatus::Failed,
1035                None,
1036                Some(format!("subgraph failed: {e}")),
1037                0,
1038            ),
1039        }
1040    }
1041
1042    /// `collect: {into, mode}` — write the nested step's output into a var.
1043    fn apply_collect(&mut self, run_id: &str, collect: &Value, out: &Value) {
1044        if let Some(into) = collect.get("into").and_then(Value::as_str) {
1045            let mode = collect
1046                .get("mode")
1047                .and_then(Value::as_str)
1048                .unwrap_or("overwrite")
1049                .to_string();
1050            if let Some(r) = self.runs.get_mut(run_id) {
1051                r.write_var(into, out.clone(), &mode);
1052            }
1053        }
1054    }
1055
1056    /// Cancel every child (turn worker) and mark every non-terminal scoped step
1057    /// under `prefix` cancelled. `prefix` is either an instance scope
1058    /// (`par{a}` — the race winner cancelling its losers) or the parent step's
1059    /// own id (`each` — a failure path cancelling every instance it spawned).
1060    pub(crate) fn cancel_scoped_children(&mut self, run_id: &str, prefix: &str) {
1061        let nodes: Vec<_> = self
1062            .children
1063            .iter()
1064            .filter(|(_, c)| matches!(&c.kind, super::children::ChildKind::StepTurn { run, step, .. } if run == run_id && (under_scope(step, prefix) || step == prefix)))
1065            .map(|(n, _)| *n)
1066            .collect();
1067        for n in nodes {
1068            self.children.cancel(n, "nested step cancelled");
1069        }
1070        let timers = self.timers.owned_by(|o| {
1071            o["run"].as_str() == Some(run_id)
1072                && o["step"].as_str().is_some_and(|s| under_scope(s, prefix))
1073        });
1074        for t in timers {
1075            let _ = self.timers.disarm(&self.durable, &t);
1076        }
1077        // Pruning `pending` here is reentrant-safe: `poll_pending` collects by
1078        // target, not by index, precisely because this runs underneath it.
1079        self.pending.retain(|p| !matches!(&p.target, super::reactor::Target::Step(r, s) if r == run_id && under_scope(s, prefix)));
1080        if let Some(run) = self.runs.get_mut(run_id) {
1081            for (id, st) in run.steps.iter_mut() {
1082                if under_scope(id, prefix) && !st.status.is_terminal() {
1083                    st.status = StepStatus::Cancelled;
1084                    st.finished = Some(now_ms());
1085                }
1086            }
1087            run.touch();
1088        }
1089    }
1090
1091    /// A scoped step finished: advance its parent (and, if the parent's own
1092    /// completion cascades, its grandparent — `nested_advance` recurses through
1093    /// `finish_step`).
1094    pub(crate) fn on_scoped_step_done(&mut self, run_id: &str, scoped: &str) {
1095        if let Some(parent) = parent_of(scoped).map(str::to_string) {
1096            // Strip the element/branch suffix to find the parent's own id.
1097            let parent_step_id = strip_scope_suffix(&parent);
1098            self.nested_advance(run_id, &parent_step_id);
1099        }
1100    }
1101}
1102
1103/// `each[3]` → `each`; `par{a}` → `par`; `sub` → `sub`; nested `x[1].y[2]` → `x[1].y`.
1104pub fn strip_scope_suffix(scoped_parent: &str) -> String {
1105    let (head, last) = match scoped_parent.rsplit_once('.') {
1106        Some((h, l)) => (Some(h), l),
1107        None => (None, scoped_parent),
1108    };
1109    let base = last.split(['[', '{']).next().unwrap_or(last);
1110    match head {
1111        Some(h) => format!("{h}.{base}"),
1112        None => base.to_string(),
1113    }
1114}
1115
1116/// Whether the scoped step id `id` lives *under* the scope `prefix`, at any
1117/// depth: a subgraph body step (`prefix.step`), or a step of one of `prefix`'s
1118/// element/branch instances (`prefix[3].step`, `prefix{a}.step`). The
1119/// delimiter test is what makes it a scope test rather than a string test —
1120/// `eachother.x` is not under `each`. A plain `starts_with("{prefix}.")` was
1121/// only ever true for the subgraph form, so the `foreach`/`parallel` failure
1122/// paths and the `race` timeout — which all pass the parent's own id, whose
1123/// children are the indexed/branch forms — cancelled nothing at all.
1124fn under_scope(id: &str, prefix: &str) -> bool {
1125    id.strip_prefix(prefix)
1126        .is_some_and(|rest| rest.starts_with(['.', '[', '{']))
1127}
1128
1129fn seg_label(seg: &Segment) -> String {
1130    match (&seg.index, &seg.branch) {
1131        (Some(i), _) => format!("{}[{i}]", seg.name),
1132        (_, Some(b)) => format!("{}{{{b}}}", seg.name),
1133        _ => seg.name.clone(),
1134    }
1135}
1136
1137/// The element `item` at `ix` from a foreach progress record. Items may be
1138/// artifact-backed (dereferenced by the runtime through `items_of`); here the
1139/// inline case is served and the artifact case falls back to `null` — the
1140/// runtime pre-resolves the item into `scoped_data` for artifact-backed loops.
1141fn element_item(progress: &Value, ix: usize) -> Option<Value> {
1142    progress["items"]
1143        .as_array()
1144        .and_then(|a| a.get(ix).cloned())
1145}
1146
1147fn batch_of(progress: &Value, ix: usize) -> Option<usize> {
1148    let size = progress["size"].as_u64()? as usize;
1149    Some(ix / size.max(1))
1150}
1151
1152fn collect_results(results: &Map<String, Value>, total: usize) -> Value {
1153    Value::Array(
1154        (0..total)
1155            .map(|i| results.get(&i.to_string()).cloned().unwrap_or(Value::Null))
1156            .collect(),
1157    )
1158}
1159
1160fn iterate_output(progress: &Value, results: &[Value]) -> Value {
1161    if progress["collect"].is_null() && !results.is_empty() {
1162        results.last().cloned().unwrap_or(Value::Null)
1163    } else {
1164        Value::Array(results.to_vec())
1165    }
1166}
1167
1168fn unreachable_wf() -> std::sync::Arc<Workflow> {
1169    // Only reached if the definition vanished mid-scheduling; an empty
1170    // workflow makes `resolve_step` return None and the guard skips.
1171    std::sync::Arc::new(Workflow {
1172        // A synthetic body workflow is never triggered, so it is about
1173        // nothing, and it is not addressable as a tool.
1174        key: None,
1175        tool: None,
1176        state: Default::default(),
1177        name: String::new(),
1178        version: 3,
1179        priority: Default::default(),
1180        unload: Default::default(),
1181        durable: None,
1182        description: None,
1183        armed: false,
1184        inputs_schema: None,
1185        concurrency: Default::default(),
1186        limits: Default::default(),
1187        outputs_schema: None,
1188        steps: BTreeMap::new(),
1189        hash: String::new(),
1190        definition: Value::Null,
1191    })
1192}
1193
1194/// The state of one body instance.
1195pub enum BodyState {
1196    Waiting,
1197    Done,
1198    Failed(String),
1199}
1200
1201pub(crate) trait StatusLabel {
1202    fn as_label(&self) -> &'static str;
1203}
1204impl StatusLabel for StepStatus {
1205    fn as_label(&self) -> &'static str {
1206        match self {
1207            StepStatus::Pending => "pending",
1208            StepStatus::Running => "running",
1209            StepStatus::Done => "done",
1210            StepStatus::Failed => "failed",
1211            StepStatus::Skipped => "skipped",
1212            StepStatus::Pruned => "pruned",
1213            StepStatus::Cancelled => "cancelled",
1214            StepStatus::Timeout => "timeout",
1215            StepStatus::Suspended => "suspended",
1216        }
1217    }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223
1224    #[test]
1225    fn scoped_ids_parse_and_strip() {
1226        let segs = parse_scoped("each[3].classify");
1227        assert_eq!(segs.len(), 2);
1228        assert_eq!(
1229            segs[0],
1230            Segment {
1231                name: "each".into(),
1232                index: Some(3),
1233                branch: None
1234            }
1235        );
1236        assert_eq!(
1237            segs[1],
1238            Segment {
1239                name: "classify".into(),
1240                index: None,
1241                branch: None
1242            }
1243        );
1244        let segs = parse_scoped("par{a}.inner[2].leaf");
1245        assert_eq!(segs[0].branch.as_deref(), Some("a"));
1246        assert_eq!(segs[1].index, Some(2));
1247        assert_eq!(scoped_id("each[3]", "classify"), "each[3].classify");
1248        assert_eq!(parent_of("each[3].classify"), Some("each[3]"));
1249        assert_eq!(strip_scope_suffix("each[3]"), "each");
1250        assert_eq!(strip_scope_suffix("par{a}"), "par");
1251        assert_eq!(strip_scope_suffix("x[1].y[2]"), "x[1].y");
1252        assert!(is_scoped("a.b") && !is_scoped("a"));
1253        // Cancellation scope: the element/branch forms are children of the
1254        // parent's own id too, and a longer name that merely shares the
1255        // prefix is not.
1256        assert!(under_scope("each[0].work", "each"));
1257        assert!(under_scope("par{a}.work", "par"));
1258        assert!(under_scope("sub.work", "sub"));
1259        assert!(under_scope("each[0].inner{b}.leaf", "each[0].inner"));
1260        assert!(!under_scope("eachother.work", "each"));
1261        assert!(!under_scope("each", "each"));
1262        assert_eq!(
1263            collect_results(&[("0".to_string(), json!(1))].into_iter().collect(), 2),
1264            json!([1, null])
1265        );
1266    }
1267}