Skip to main content

agentd/runtime/
nested.rs

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