Skip to main content

agentd/engine/
run.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **run record** and the **scheduler** (RFC 0027 §6, §7, §9): a run is a
3//! durable transition log over a workflow's DAG — per-step `{status, attempt,
4//! started, finished, output, error}`, `vars`, the start payload, budgets and
5//! the terminal outcome. The scheduler is pure: given a workflow and a run it
6//! names the steps that are ready (all `depends_on` terminal, `when` true),
7//! applies step outcomes (`on_error` routing, `goto` recovery edges), and
8//! decides the run's terminal state (`finish` reached, failed, cancelled, or
9//! stalled — no ready step and no finish).
10
11use super::model::{OnError, Step, Workflow};
12use super::template::{self, Data};
13use crate::state::now_ms;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value, json};
16use std::collections::BTreeMap;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "snake_case")]
20pub enum StepStatus {
21    #[default]
22    Pending,
23    Running,
24    Done,
25    Failed,
26    Skipped,
27    Cancelled,
28    Timeout,
29    /// Waiting on something durable (a timer, a gate, a signal, a budget).
30    Suspended,
31    /// The branch this step sits on was not taken, so it will never run — and
32    /// neither will anything that depends ONLY on it.
33    ///
34    /// Distinct from `Skipped`, and the distinction is load-bearing. A skipped
35    /// step SATISFIES its dependents: that is what lets a workflow with several
36    /// start nodes fire one and still run the steps below the others, and what
37    /// lets an uneven join proceed without LangGraph's `defer=True`. A pruned
38    /// step must not satisfy anything, or the tail of a branch nobody chose
39    /// runs anyway — which it did, before this existed.
40    Pruned,
41}
42
43impl StepStatus {
44    pub fn is_terminal(self) -> bool {
45        matches!(
46            self,
47            StepStatus::Done
48                | StepStatus::Failed
49                | StepStatus::Skipped
50                | StepStatus::Pruned
51                | StepStatus::Cancelled
52                | StepStatus::Timeout
53        )
54    }
55    /// Counts as satisfied for dependents (`done | skipped`).
56    pub fn is_satisfied(self) -> bool {
57        matches!(self, StepStatus::Done | StepStatus::Skipped)
58    }
59}
60
61/// One step's durable state.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
63pub struct StepState {
64    #[serde(default)]
65    pub status: StepStatus,
66    #[serde(default)]
67    pub attempt: u32,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub started: Option<u64>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub finished: Option<u64>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub output: Option<Value>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub error: Option<String>,
76    /// The suspension detail (`{kind, timer?, deadline_ms?, …}`).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub wait: Option<Value>,
79    /// The turn worker / child handle executing this step (not durable).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub worker: Option<String>,
82    /// Scheduled explicitly (an `on_error: goto` target / a `switch` case):
83    /// runs even if its dependencies are not terminal.
84    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
85    pub forced: bool,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
89#[serde(rename_all = "snake_case")]
90pub enum RunStatus {
91    #[default]
92    Pending,
93    Running,
94    /// Every non-terminal step is suspended (timers/gates/budget).
95    Suspended,
96    Paused,
97    Completed,
98    Failed,
99    Refused,
100    Cancelled,
101    Stalled,
102}
103
104impl RunStatus {
105    pub fn is_terminal(self) -> bool {
106        matches!(
107            self,
108            RunStatus::Completed
109                | RunStatus::Failed
110                | RunStatus::Refused
111                | RunStatus::Cancelled
112                | RunStatus::Stalled
113        )
114    }
115    pub fn as_str(self) -> &'static str {
116        match self {
117            RunStatus::Pending => "pending",
118            RunStatus::Running => "running",
119            RunStatus::Suspended => "suspended",
120            RunStatus::Paused => "paused",
121            RunStatus::Completed => "completed",
122            RunStatus::Failed => "failed",
123            RunStatus::Refused => "refused",
124            RunStatus::Cancelled => "cancelled",
125            RunStatus::Stalled => "stalled",
126        }
127    }
128}
129
130/// How the run started (RFC 0027 §3 `run.start`).
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
132pub struct Start {
133    pub node: String,
134    #[serde(default)]
135    pub payload: Value,
136    #[serde(default)]
137    pub ts: u64,
138}
139
140/// The durable run record (RFC 0025 §3.3 `run`, RFC 0027 §9).
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct RunState {
143    /// Stop this run just before the named step starts (a breakpoint set with
144    /// `workflow.pause {before_step}`). Durable, so it survives a restart —
145    /// which is the point: the interesting bugs are the ones that need one.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub break_before: Option<String>,
148    pub id: String,
149    pub workflow: String,
150    pub workflow_hash: String,
151    #[serde(default)]
152    pub inputs: Value,
153    #[serde(default)]
154    pub status: RunStatus,
155    #[serde(default)]
156    pub start: Start,
157    #[serde(default)]
158    pub steps: BTreeMap<String, StepState>,
159    #[serde(default)]
160    pub vars: Map<String, Value>,
161    #[serde(default)]
162    pub tokens: u64,
163    #[serde(default)]
164    pub steps_run: u32,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub output: Option<Value>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub error: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub task: Option<String>,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub principal: Option<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub conversation: Option<String>,
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub children: Vec<String>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub parent: Option<Value>,
179    #[serde(default)]
180    pub attempt: u32,
181    #[serde(default)]
182    pub created: u64,
183    #[serde(default)]
184    pub updated: u64,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub finished: Option<u64>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub deadline_ms: Option<u64>,
189    #[serde(skip)]
190    pub dirty: bool,
191}
192
193impl RunState {
194    pub fn new(id: &str, wf: &Workflow, start: Start, inputs: Value) -> RunState {
195        let now = now_ms();
196        let mut steps = BTreeMap::new();
197        for s in wf.steps.keys() {
198            steps.insert(s.clone(), StepState::default());
199        }
200        // The fired start node is done with the payload as its output; sibling
201        // start nodes are skipped.
202        for s in wf.start_steps() {
203            let st = steps.get_mut(&s.id).expect("present");
204            if s.id == start.node {
205                st.status = StepStatus::Done;
206                st.output = Some(start.payload.clone());
207                st.started = Some(now);
208                st.finished = Some(now);
209                st.attempt = 1;
210            } else {
211                st.status = StepStatus::Skipped;
212            }
213        }
214        RunState {
215            break_before: None,
216            id: id.to_string(),
217            workflow: wf.name.clone(),
218            workflow_hash: wf.hash.clone(),
219            inputs,
220            status: RunStatus::Running,
221            start,
222            steps,
223            vars: Map::new(),
224            tokens: 0,
225            steps_run: 0,
226            output: None,
227            error: None,
228            task: None,
229            principal: None,
230            conversation: None,
231            children: Vec::new(),
232            parent: None,
233            attempt: 1,
234            created: now,
235            updated: now,
236            finished: None,
237            deadline_ms: wf.limits.deadline_ms.map(|d| now + d),
238            dirty: true,
239        }
240    }
241
242    pub fn touch(&mut self) {
243        self.updated = now_ms();
244        self.dirty = true;
245    }
246
247    pub fn step(&self, id: &str) -> Option<&StepState> {
248        self.steps.get(id)
249    }
250
251    /// The template data view of this run (RFC 0027 §3). `memory` and `env`
252    /// are supplied by the caller (`env` curated + secret-free).
253    pub fn data(&self, env: Value, memory: Value) -> Data {
254        let mut d = Data::new();
255        d.insert("inputs".into(), self.inputs.clone());
256        d.insert(
257            "run".into(),
258            json!({"id": self.id, "workflow": self.workflow, "start": self.start, "principal": self.principal, "task": self.task, "attempt": self.attempt, "status": self.status}),
259        );
260        d.insert(
261            "steps".into(),
262            Value::Object(
263                self.steps
264                    .iter()
265                    .map(|(k, s)| (k.clone(), json!({"status": s.status, "output": s.output, "error": s.error, "attempt": s.attempt})))
266                    .collect(),
267            ),
268        );
269        d.insert("vars".into(), Value::Object(self.vars.clone()));
270        d.insert("env".into(), env);
271        d.insert("memory".into(), memory);
272        d
273    }
274
275    /// Mark a step running (attempt +1). Returns the attempt.
276    pub fn begin_step(&mut self, id: &str) -> u32 {
277        let attempt = {
278            let st = self.steps.entry(id.to_string()).or_default();
279            st.status = StepStatus::Running;
280            st.attempt += 1;
281            st.started = Some(now_ms());
282            st.finished = None;
283            st.error = None;
284            st.wait = None;
285            st.forced = false;
286            st.attempt
287        };
288        if !self.status.is_terminal() {
289            self.status = RunStatus::Running;
290        }
291        self.touch();
292        attempt
293    }
294
295    /// Record a step's terminal outcome.
296    pub fn end_step(
297        &mut self,
298        id: &str,
299        status: StepStatus,
300        output: Option<Value>,
301        error: Option<String>,
302    ) {
303        let st = self.steps.entry(id.to_string()).or_default();
304        st.status = status;
305        st.finished = Some(now_ms());
306        st.output = output;
307        st.error = error;
308        st.wait = None;
309        st.worker = None;
310        self.steps_run += 1;
311        self.touch();
312    }
313
314    /// Record a suspension (timer/gate/budget).
315    pub fn suspend_step(&mut self, id: &str, wait: Value) {
316        let st = self.steps.entry(id.to_string()).or_default();
317        st.status = StepStatus::Suspended;
318        st.wait = Some(wait);
319        self.touch();
320    }
321
322    /// Terminal transition of the run.
323    pub fn finish(&mut self, status: RunStatus, output: Option<Value>, error: Option<String>) {
324        self.status = status;
325        self.output = output;
326        self.error = error;
327        self.finished = Some(now_ms());
328        // Cancel every non-terminal step.
329        for st in self.steps.values_mut() {
330            if !st.status.is_terminal() {
331                st.status = StepStatus::Cancelled;
332                st.finished = Some(now_ms());
333            }
334        }
335        self.touch();
336    }
337
338    /// Apply an `assign`/`transform` write with a reducer mode.
339    pub fn write_var(&mut self, key: &str, value: Value, mode: &str) {
340        let cur = self.vars.remove(key);
341        let next = match (mode, cur) {
342            ("append", Some(Value::Array(mut a))) => {
343                match value {
344                    Value::Array(more) => a.extend(more),
345                    other => a.push(other),
346                }
347                Value::Array(a)
348            }
349            ("append", Some(other)) => json!([other, value]),
350            ("append", None) => match value {
351                Value::Array(a) => Value::Array(a),
352                other => json!([other]),
353            },
354            ("merge", Some(Value::Object(mut o))) => {
355                if let Value::Object(more) = value {
356                    for (k, v) in more {
357                        o.insert(k, v);
358                    }
359                }
360                Value::Object(o)
361            }
362            ("union", Some(Value::Array(mut a))) => {
363                if let Value::Array(more) = value {
364                    for v in more {
365                        if !a.contains(&v) {
366                            a.push(v);
367                        }
368                    }
369                } else if !a.contains(&value) {
370                    a.push(value);
371                }
372                Value::Array(a)
373            }
374            (_, _) => value,
375        };
376        self.vars.insert(key.to_string(), next);
377        self.touch();
378    }
379
380    /// The steps counted as terminal/pending — for status views.
381    pub fn progress(&self) -> Value {
382        let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
383        for s in self.steps.values() {
384            *counts
385                .entry(match s.status {
386                    StepStatus::Pending => "pending",
387                    StepStatus::Running => "running",
388                    StepStatus::Done => "done",
389                    StepStatus::Failed => "failed",
390                    StepStatus::Skipped => "skipped",
391                    StepStatus::Pruned => "pruned",
392                    StepStatus::Cancelled => "cancelled",
393                    StepStatus::Timeout => "timeout",
394                    StepStatus::Suspended => "suspended",
395                })
396                .or_default() += 1;
397        }
398        json!(counts)
399    }
400
401    pub fn summary(&self) -> Value {
402        json!({
403            "id": self.id, "workflow": self.workflow, "status": self.status, "start": self.start.node,
404            "steps": self.progress(), "tokens": self.tokens, "created": self.created, "updated": self.updated,
405            "finished": self.finished, "output": self.output, "error": self.error, "task": self.task, "principal": self.principal,
406        })
407    }
408}
409
410/// What the scheduler wants done next.
411#[derive(Debug, Clone, PartialEq)]
412pub enum Next {
413    /// Start these steps (all deps satisfied, `when` true).
414    Ready(Vec<String>),
415    /// Nothing ready but work is in flight / suspended.
416    Waiting,
417    /// The run is stalled: no ready step, nothing in flight, no finish reached.
418    Stalled,
419    /// The run is terminal already.
420    Terminal,
421}
422
423/// Compute the ready steps. `when` guards are evaluated over `data`; a false
424/// guard skips the step (durably) — hence `&mut RunState`.
425pub fn schedule(wf: &Workflow, run: &mut RunState, data: &Data) -> Result<Next, String> {
426    if run.status.is_terminal() {
427        return Ok(Next::Terminal);
428    }
429    let mut ready = Vec::new();
430    let mut in_flight = false;
431    let mut changed = true;
432    // Iterate to a fixpoint so a newly-skipped step lets its dependents proceed.
433    while changed {
434        changed = false;
435        for id in wf.topo_order() {
436            let step = &wf.steps[&id];
437            let st = run.steps.get(&id).cloned().unwrap_or_default();
438            match st.status {
439                StepStatus::Running => {
440                    in_flight = true;
441                    continue;
442                }
443                StepStatus::Suspended => {
444                    in_flight = true;
445                    continue;
446                }
447                s if s.is_terminal() => continue,
448                _ => {}
449            }
450            if ready.contains(&id) {
451                continue;
452            }
453            if st.forced {
454                ready.push(id.clone());
455                continue;
456            }
457            // Transitive pruning. A step whose dependencies are ALL pruned can
458            // never run, so it is pruned too and the wave carries down the dead
459            // branch. One live dependency is enough to keep the step alive —
460            // that is the uneven-join case, and it is why this is not simply
461            // "any pruned dep prunes me".
462            let pruned_deps = step
463                .depends_on
464                .iter()
465                .filter(|d| {
466                    run.steps
467                        .get(*d)
468                        .is_some_and(|s| s.status == StepStatus::Pruned)
469                })
470                .count();
471            if !step.depends_on.is_empty() && pruned_deps == step.depends_on.len() {
472                run.end_step(&id, StepStatus::Pruned, None, None);
473                changed = true;
474                continue;
475            }
476            // A pruned dependency is not waited on: the live paths decide.
477            let deps_ok = step.depends_on.iter().all(|d| {
478                run.steps
479                    .get(d)
480                    .is_some_and(|s| s.status.is_satisfied() || s.status == StepStatus::Pruned)
481            });
482            let deps_failed = step.depends_on.iter().any(|d| {
483                run.steps.get(d).is_some_and(|s| {
484                    matches!(
485                        s.status,
486                        StepStatus::Failed | StepStatus::Cancelled | StepStatus::Timeout
487                    )
488                })
489            });
490            if deps_failed {
491                // A failed dependency that was not routed (on_error fail already
492                // failed the run) — treat like cancelled downstream.
493                continue;
494            }
495            if !deps_ok {
496                continue;
497            }
498            if let Some(w) = &step.when {
499                let expr = w.trim().trim_start_matches("CEL:").trim();
500                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
501                match crate::cel::eval_bool(expr, &vars) {
502                    Ok(true) => {}
503                    Ok(false) => {
504                        // Not taken, so nothing that depends only on it runs.
505                        run.end_step(&id, StepStatus::Pruned, None, None);
506                        changed = true;
507                        continue;
508                    }
509                    Err(e) => return Err(format!("step {id:?}: when: {e}")),
510                }
511            }
512            ready.push(id.clone());
513        }
514    }
515    if !ready.is_empty() {
516        return Ok(Next::Ready(ready));
517    }
518    if in_flight {
519        return Ok(Next::Waiting);
520    }
521    Ok(Next::Stalled)
522}
523
524/// Apply a failed step's `on_error` policy: returns the steps to schedule
525/// next (a `goto` target) or `Err(reason)` when the run must fail.
526pub fn route_failure(
527    wf: &Workflow,
528    run: &mut RunState,
529    step: &Step,
530    error: &str,
531) -> Result<Vec<String>, String> {
532    match &step.on_error {
533        OnError::Fail => Err(format!("step {:?} failed: {error}", step.id)),
534        OnError::Continue => {
535            // Downstream sees the step as satisfied-with-error: mark it done
536            // with an error output so `steps.<id>.error` is inspectable.
537            let st = run.steps.entry(step.id.clone()).or_default();
538            st.status = StepStatus::Done;
539            st.error = Some(error.to_string());
540            if st.output.is_none() {
541                st.output = Some(json!({"error": error}));
542            }
543            run.touch();
544            Ok(Vec::new())
545        }
546        OnError::Goto(target) => {
547            if !wf.steps.contains_key(target) {
548                return Err(format!(
549                    "step {:?}: on_error goto {target:?} does not exist",
550                    step.id
551                ));
552            }
553            // The recovery target runs even if its deps are not terminal.
554            let st = run.steps.entry(target.clone()).or_default();
555            st.status = StepStatus::Pending;
556            st.forced = true;
557            run.touch();
558            Ok(vec![target.clone()])
559        }
560    }
561}
562
563/// Whether the run's deadline passed.
564pub fn deadline_passed(run: &RunState) -> bool {
565    run.deadline_ms.is_some_and(|d| now_ms() >= d)
566}
567
568/// The `env` view (curated, secret-free) the templates see.
569pub fn env_view(
570    instance: &str,
571    run_id: &str,
572    instruction: Option<&str>,
573    prompt: Option<&str>,
574) -> Value {
575    json!({
576        "instance": instance,
577        "run": run_id,
578        "ts": now_ms(),
579        "instruction": instruction,
580        // The one-shot task (`--prompt`); the sugar workflow reads it.
581        "prompt": prompt,
582    })
583}
584
585/// Render a step's spec against the run data (every field, recursively).
586pub fn render_spec(step: &Step, data: &Data) -> Result<Map<String, Value>, String> {
587    let mut out = Map::new();
588    for (k, v) in &step.spec {
589        if super::model::is_raw_field(&step.kind, k) {
590            out.insert(k.clone(), v.clone());
591            continue;
592        }
593        out.insert(
594            k.clone(),
595            template::render(v, data).map_err(|e| format!("step {:?}: {k}: {e}", step.id))?,
596        );
597    }
598    Ok(out)
599}
600
601// These tests drive workflows with `CEL:` when-clauses, so the whole module
602// needs the `cel` feature (a default build evaluates CEL fail-closed).
603#[cfg(all(test, feature = "cel"))]
604mod tests {
605    use super::*;
606    use crate::engine::model::parse_workflow;
607
608    fn start_at(node: &str) -> Start {
609        Start {
610            node: node.into(),
611            payload: json!({}),
612            ts: 0,
613        }
614    }
615
616    /// The branch nobody chose must not run, and neither must its TAIL — which
617    /// it did before `Pruned` existed, because `Skipped` satisfies dependents.
618    /// The join is why this cannot be "any pruned dep prunes me": `fin` has one
619    /// pruned parent and one live one, and must still run.
620    #[test]
621    fn an_untaken_branch_prunes_its_tail_but_not_a_live_join() {
622        let w = parse_workflow(&json!({
623            "name": "w", "steps": {
624                "go":  {"kind": "once"},
625                "la":  {"kind": "noop", "depends_on": ["go"]},
626                "ra":  {"kind": "noop", "depends_on": ["go"]},
627                "la2": {"kind": "noop", "depends_on": ["la"]},
628                "ra2": {"kind": "noop", "depends_on": ["ra"]},
629                "fin": {"kind": "finish", "depends_on": ["la2", "ra2"], "status": "completed"}
630            }
631        }))
632        .unwrap();
633        let mut run = RunState::new("r", &w, start_at("go"), json!({}));
634        run.end_step("ra", StepStatus::Pruned, None, None);
635        run.end_step("la", StepStatus::Done, None, None);
636        let data = run.data(env_view("i", "r", None, None), json!({}));
637        let _ = schedule(&w, &mut run, &data).unwrap();
638        assert_eq!(
639            run.steps["ra2"].status,
640            StepStatus::Pruned,
641            "the dead branch's tail must be pruned, not run"
642        );
643
644        run.end_step("la2", StepStatus::Done, None, None);
645        let data = run.data(env_view("i", "r", None, None), json!({}));
646        match schedule(&w, &mut run, &data).unwrap() {
647            Next::Ready(r) => assert!(
648                r.iter().any(|s| s == "fin"),
649                "a join with one pruned and one live parent must run, got {r:?}"
650            ),
651            other => panic!("expected fin ready, got {other:?}"),
652        }
653    }
654
655    /// Sibling start nodes stay `Skipped`, which SATISFIES dependents — several
656    /// triggers, one fires, the graph below still runs. Pruning must not have
657    /// swallowed that distinction.
658    #[test]
659    fn sibling_start_nodes_still_satisfy_their_dependents() {
660        let w = parse_workflow(&json!({
661            "name": "w", "steps": {
662                "a":    {"kind": "once"},
663                "b":    {"kind": "manual"},
664                "work": {"kind": "noop", "depends_on": ["a", "b"]},
665                "fin":  {"kind": "finish", "depends_on": ["work"], "status": "completed"}
666            }
667        }))
668        .unwrap();
669        let mut run = RunState::new("r", &w, start_at("a"), json!({}));
670        assert_eq!(run.steps["b"].status, StepStatus::Skipped);
671        let data = run.data(env_view("i", "r", None, None), json!({}));
672        match schedule(&w, &mut run, &data).unwrap() {
673            Next::Ready(r) => assert!(
674                r.iter().any(|s| s == "work"),
675                "a step below several start nodes must run when one fired, got {r:?}"
676            ),
677            other => panic!("expected work ready, got {other:?}"),
678        }
679    }
680
681    fn wf() -> Workflow {
682        parse_workflow(&json!({
683            "name": "w", "steps": {
684                "s": {"kind": "once"},
685                "a": {"kind": "noop", "depends_on": ["s"]},
686                "b": {"kind": "noop", "depends_on": ["s"], "when": "CEL: inputs.go == true"},
687                "c": {"kind": "noop", "depends_on": ["a", "b"], "on_error": "goto:fix"},
688                "fix": {"kind": "noop", "depends_on": ["c"]},
689                "f": {"kind": "finish", "depends_on": ["c"], "status": "completed", "output": "{{vars.x | none}}"}
690            }
691        }))
692        .unwrap()
693    }
694
695    #[cfg(feature = "cel")]
696    #[test]
697    fn scheduling_guards_failures_and_terminal_states() {
698        let w = wf();
699        let mut run = RunState::new(
700            "r1",
701            &w,
702            Start {
703                node: "s".into(),
704                payload: json!({"p": 1}),
705                ts: 0,
706            },
707            json!({"go": false}),
708        );
709        assert_eq!(run.steps["s"].status, StepStatus::Done);
710        assert_eq!(run.steps["s"].output, Some(json!({"p": 1})));
711        let data = run.data(env_view("i", "r1", None, None), json!({}));
712        // a is ready; b's guard is false → skipped; c waits on a.
713        assert_eq!(
714            schedule(&w, &mut run, &data).unwrap(),
715            Next::Ready(vec!["a".to_string()])
716        );
717        // A false guard PRUNES: "do not do this" now also means "do not do the
718        // things that exist only because of this". `c` still runs below, because
719        // its other parent `a` is live — pruning follows dead paths, not steps.
720        assert_eq!(run.steps["b"].status, StepStatus::Pruned);
721        run.begin_step("a");
722        let data = run.data(env_view("i", "r1", None, None), json!({}));
723        assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Waiting);
724        run.end_step("a", StepStatus::Done, Some(json!("A")), None);
725        let data = run.data(env_view("i", "r1", None, None), json!({}));
726        assert_eq!(
727            schedule(&w, &mut run, &data).unwrap(),
728            Next::Ready(vec!["c".to_string()])
729        );
730        // c fails → goto fix.
731        run.begin_step("c");
732        run.end_step("c", StepStatus::Failed, None, Some("boom".into()));
733        let next = route_failure(&w, &mut run, w.step("c").unwrap(), "boom").unwrap();
734        assert_eq!(next, vec!["fix".to_string()]);
735        run.begin_step("fix");
736        run.end_step("fix", StepStatus::Done, None, None);
737        // f depends on c which FAILED (not satisfied) → nothing ready, nothing in flight → stalled.
738        let data = run.data(env_view("i", "r1", None, None), json!({}));
739        assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Stalled);
740        run.finish(RunStatus::Stalled, None, Some("stalled".into()));
741        assert!(run.status.is_terminal());
742        let data = run.data(env_view("i", "r1", None, None), json!({}));
743        assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Terminal);
744        // Continue policy marks done-with-error.
745        let mut run2 = RunState::new(
746            "r2",
747            &w,
748            Start {
749                node: "s".into(),
750                payload: json!({}),
751                ts: 0,
752            },
753            json!({"go": true}),
754        );
755        let mut c = w.step("c").unwrap().clone();
756        c.on_error = OnError::Continue;
757        run2.begin_step("c");
758        run2.end_step("c", StepStatus::Failed, None, Some("e".into()));
759        assert!(route_failure(&w, &mut run2, &c, "e").unwrap().is_empty());
760        assert_eq!(run2.steps["c"].status, StepStatus::Done);
761        assert_eq!(run2.steps["c"].error.as_deref(), Some("e"));
762        // Fail policy errors.
763        let mut a = w.step("a").unwrap().clone();
764        a.on_error = OnError::Fail;
765        assert!(route_failure(&w, &mut run2, &a, "e").is_err());
766    }
767
768    #[test]
769    fn vars_reducers_and_serialization() {
770        let w = wf();
771        let mut run = RunState::new("r", &w, Start::default(), json!({}));
772        run.write_var("l", json!([1]), "overwrite");
773        run.write_var("l", json!(2), "append");
774        run.write_var("l", json!([3, 4]), "append");
775        assert_eq!(run.vars["l"], json!([1, 2, 3, 4]));
776        run.write_var("l", json!([4, 5]), "union");
777        assert_eq!(run.vars["l"], json!([1, 2, 3, 4, 5]));
778        run.write_var("o", json!({"a": 1}), "overwrite");
779        run.write_var("o", json!({"b": 2}), "merge");
780        assert_eq!(run.vars["o"], json!({"a": 1, "b": 2}));
781        run.write_var("o", json!(7), "overwrite");
782        assert_eq!(run.vars["o"], json!(7));
783        let v = serde_json::to_value(&run).unwrap();
784        let back: RunState = serde_json::from_value(v).unwrap();
785        assert_eq!(back.vars, run.vars);
786        assert!(!back.dirty);
787        assert_eq!(back.summary()["workflow"], json!("w"));
788        // A rendered spec.
789        let data = run.data(
790            env_view("inst", "r", Some("brief"), None),
791            json!({"k": "v"}),
792        );
793        let mut s = w.step("f").unwrap().clone();
794        s.spec
795            .insert("extra".into(), json!("{{env.instruction}}/{{memory.k}}"));
796        let rendered = render_spec(&s, &data).unwrap();
797        assert_eq!(rendered["output"], json!("none"));
798        assert_eq!(rendered["extra"], json!("brief/v"));
799        assert!(!deadline_passed(&run));
800    }
801}