Skip to main content

agentd/engine/
run.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **run record** and the **scheduler**: a run is a durable transition
3//! 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.
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 memoization key for this attempt, held from dispatch (where the
80    /// `cache {key, ttl}` miss is detected) until the terminal outcome (where
81    /// the output is stored under it). It needs its own slot rather than
82    /// riding `wait`: a step that suspends — `agent`, `human`, `subagent`,
83    /// `foreach`, `wait` — overwrites `wait` with its suspension detail, so a
84    /// key parked there would survive only for the kinds cheap enough not to
85    /// need caching. Durable, because the suspension it spans can outlive the
86    /// process.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub cache_key: Option<String>,
89    /// The turn worker / child handle executing this step (not durable).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub worker: Option<String>,
92    /// Scheduled explicitly (an `on_error: goto` target / a `switch` case):
93    /// runs even if its dependencies are not terminal.
94    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
95    pub forced: bool,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
99#[serde(rename_all = "snake_case")]
100pub enum RunStatus {
101    #[default]
102    Pending,
103    Running,
104    /// Every non-terminal step is suspended (timers/gates/budget).
105    Suspended,
106    Paused,
107    Completed,
108    Failed,
109    Refused,
110    Cancelled,
111    Stalled,
112}
113
114impl RunStatus {
115    pub fn is_terminal(self) -> bool {
116        matches!(
117            self,
118            RunStatus::Completed
119                | RunStatus::Failed
120                | RunStatus::Refused
121                | RunStatus::Cancelled
122                | RunStatus::Stalled
123        )
124    }
125    pub fn as_str(self) -> &'static str {
126        match self {
127            RunStatus::Pending => "pending",
128            RunStatus::Running => "running",
129            RunStatus::Suspended => "suspended",
130            RunStatus::Paused => "paused",
131            RunStatus::Completed => "completed",
132            RunStatus::Failed => "failed",
133            RunStatus::Refused => "refused",
134            RunStatus::Cancelled => "cancelled",
135            RunStatus::Stalled => "stalled",
136        }
137    }
138}
139
140/// How the run started: which start node fired, what payload it carried, and
141/// when. Visible to templates as `run.start`.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
143pub struct Start {
144    pub node: String,
145    #[serde(default)]
146    pub payload: Value,
147    #[serde(default)]
148    pub ts: u64,
149}
150
151/// The durable run record: the whole state of one run, and the only thing that
152/// has to survive a restart for the scheduler to carry on where it stopped.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct RunState {
155    /// Stop this run just before the named step starts (a breakpoint set with
156    /// `workflow.pause {before_step}`). Durable, so it survives a restart —
157    /// which is the point: the interesting bugs are the ones that need one.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub break_before: Option<String>,
160    pub id: String,
161    pub workflow: String,
162    pub workflow_hash: String,
163    #[serde(default)]
164    pub inputs: Value,
165    #[serde(default)]
166    pub status: RunStatus,
167    #[serde(default)]
168    pub start: Start,
169    #[serde(default)]
170    pub steps: BTreeMap<String, StepState>,
171    #[serde(default)]
172    pub vars: Map<String, Value>,
173    #[serde(default)]
174    pub tokens: u64,
175    #[serde(default)]
176    pub steps_run: u32,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub output: Option<Value>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub error: Option<String>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub task: Option<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub principal: Option<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub conversation: Option<String>,
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub children: Vec<String>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub parent: Option<Value>,
191    /// How many `message` hops caused this run, counted from the last trigger
192    /// that was not itself a delivered message. A run started by a schedule,
193    /// webhook or stream is depth 0; one started by an agent that a `message`
194    /// woke inherits that message's depth. `message` refuses past
195    /// `limits.max_message_depth`, which is what stops
196    /// message → turn → run → message from re-arming itself forever. Volume
197    /// alone cannot be the test: twenty unrelated workflows greeting the
198    /// operator are not a loop, and one workflow greeting itself is.
199    #[serde(default)]
200    pub msg_depth: u32,
201    /// The logical thing this run is about (the workflow's `key:`, rendered
202    /// against the trigger payload). Durable, so a restart still knows which
203    /// runs are about the same entity.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub key: Option<String>,
206    #[serde(default)]
207    pub attempt: u32,
208    #[serde(default)]
209    pub created: u64,
210    #[serde(default)]
211    pub updated: u64,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub finished: Option<u64>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub deadline_ms: Option<u64>,
216    /// Durability class, resolved at creation from the workflow (default
217    /// true). `false` ⇒ this run is memory-only: the checkpoint skips it and
218    /// a restart forgets it — the fast path for recomputable work. Restored
219    /// records (all durable by construction) default true.
220    #[serde(default = "default_durable")]
221    pub durable: bool,
222    #[serde(skip)]
223    pub dirty: bool,
224}
225
226fn default_durable() -> bool {
227    true
228}
229
230impl RunState {
231    pub fn new(id: &str, wf: &Workflow, start: Start, inputs: Value) -> RunState {
232        let now = now_ms();
233        let mut steps = BTreeMap::new();
234        for s in wf.steps.keys() {
235            steps.insert(s.clone(), StepState::default());
236        }
237        // The fired start node is done with the payload as its output; sibling
238        // start nodes are skipped.
239        for s in wf.start_steps() {
240            let st = steps.get_mut(&s.id).expect("present");
241            if s.id == start.node {
242                st.status = StepStatus::Done;
243                st.output = Some(start.payload.clone());
244                st.started = Some(now);
245                st.finished = Some(now);
246                st.attempt = 1;
247            } else {
248                st.status = StepStatus::Skipped;
249            }
250        }
251        RunState {
252            break_before: None,
253            id: id.to_string(),
254            workflow: wf.name.clone(),
255            workflow_hash: wf.hash.clone(),
256            inputs,
257            status: RunStatus::Running,
258            start,
259            steps,
260            vars: Map::new(),
261            tokens: 0,
262            steps_run: 0,
263            output: None,
264            error: None,
265            task: None,
266            principal: None,
267            conversation: None,
268            children: Vec::new(),
269            parent: None,
270            msg_depth: 0,
271            key: None,
272            attempt: 1,
273            created: now,
274            updated: now,
275            finished: None,
276            deadline_ms: wf.limits.deadline_ms.map(|d| now + d),
277            durable: wf.durable.unwrap_or(true),
278            dirty: true,
279        }
280    }
281
282    pub fn touch(&mut self) {
283        self.updated = now_ms();
284        self.dirty = true;
285    }
286
287    pub fn step(&self, id: &str) -> Option<&StepState> {
288        self.steps.get(id)
289    }
290
291    /// The template data view of this run: the root names `{{…}}` and CEL
292    /// expressions may reference. `memory` and `env` are supplied by the
293    /// caller rather than read here, because `env` must be curated and
294    /// secret-free before a template can see it.
295    pub fn data(&self, env: Value, memory: Value) -> Data {
296        let mut d = Data::new();
297        d.insert("inputs".into(), self.inputs.clone());
298        d.insert(
299            "run".into(),
300            json!({"id": self.id, "workflow": self.workflow, "start": self.start, "principal": self.principal, "task": self.task, "attempt": self.attempt, "status": self.status}),
301        );
302        d.insert(
303            "steps".into(),
304            Value::Object(
305                self.steps
306                    .iter()
307                    .map(|(k, s)| (k.clone(), json!({"status": s.status, "output": s.output, "error": s.error, "attempt": s.attempt})))
308                    .collect(),
309            ),
310        );
311        d.insert("vars".into(), Value::Object(self.vars.clone()));
312        d.insert("env".into(), env);
313        d.insert("memory".into(), memory);
314        d
315    }
316
317    /// Mark a step running (attempt +1). Returns the attempt.
318    pub fn begin_step(&mut self, id: &str) -> u32 {
319        let attempt = {
320            let st = self.steps.entry(id.to_string()).or_default();
321            st.status = StepStatus::Running;
322            st.attempt += 1;
323            st.started = Some(now_ms());
324            st.finished = None;
325            st.error = None;
326            st.wait = None;
327            st.cache_key = None;
328            st.forced = false;
329            st.attempt
330        };
331        if !self.status.is_terminal() {
332            self.status = RunStatus::Running;
333        }
334        self.touch();
335        attempt
336    }
337
338    /// Record a step's terminal outcome.
339    pub fn end_step(
340        &mut self,
341        id: &str,
342        status: StepStatus,
343        output: Option<Value>,
344        error: Option<String>,
345    ) {
346        let st = self.steps.entry(id.to_string()).or_default();
347        st.status = status;
348        st.finished = Some(now_ms());
349        st.output = output;
350        st.error = error;
351        st.wait = None;
352        st.cache_key = None;
353        st.worker = None;
354        self.steps_run += 1;
355        self.touch();
356    }
357
358    /// Record a suspension (timer/gate/budget).
359    pub fn suspend_step(&mut self, id: &str, wait: Value) {
360        let st = self.steps.entry(id.to_string()).or_default();
361        st.status = StepStatus::Suspended;
362        st.wait = Some(wait);
363        self.touch();
364    }
365
366    /// Terminal transition of the run.
367    pub fn finish(&mut self, status: RunStatus, output: Option<Value>, error: Option<String>) {
368        self.status = status;
369        self.output = output;
370        self.error = error;
371        self.finished = Some(now_ms());
372        // Cancel every non-terminal step.
373        for st in self.steps.values_mut() {
374            if !st.status.is_terminal() {
375                st.status = StepStatus::Cancelled;
376                st.finished = Some(now_ms());
377            }
378        }
379        self.touch();
380    }
381
382    /// Apply an `assign`/`transform` write with a reducer mode.
383    pub fn write_var(&mut self, key: &str, value: Value, mode: &str) {
384        let cur = self.vars.remove(key);
385        let next = match (mode, cur) {
386            ("append", Some(Value::Array(mut a))) => {
387                match value {
388                    Value::Array(more) => a.extend(more),
389                    other => a.push(other),
390                }
391                Value::Array(a)
392            }
393            ("append", Some(other)) => json!([other, value]),
394            ("append", None) => match value {
395                Value::Array(a) => Value::Array(a),
396                other => json!([other]),
397            },
398            ("merge", Some(Value::Object(mut o))) => {
399                if let Value::Object(more) = value {
400                    for (k, v) in more {
401                        o.insert(k, v);
402                    }
403                }
404                Value::Object(o)
405            }
406            ("union", Some(Value::Array(mut a))) => {
407                if let Value::Array(more) = value {
408                    for v in more {
409                        if !a.contains(&v) {
410                            a.push(v);
411                        }
412                    }
413                } else if !a.contains(&value) {
414                    a.push(value);
415                }
416                Value::Array(a)
417            }
418            (_, _) => value,
419        };
420        self.vars.insert(key.to_string(), next);
421        self.touch();
422    }
423
424    /// The steps counted as terminal/pending — for status views.
425    pub fn progress(&self) -> Value {
426        let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
427        for s in self.steps.values() {
428            *counts
429                .entry(match s.status {
430                    StepStatus::Pending => "pending",
431                    StepStatus::Running => "running",
432                    StepStatus::Done => "done",
433                    StepStatus::Failed => "failed",
434                    StepStatus::Skipped => "skipped",
435                    StepStatus::Pruned => "pruned",
436                    StepStatus::Cancelled => "cancelled",
437                    StepStatus::Timeout => "timeout",
438                    StepStatus::Suspended => "suspended",
439                })
440                .or_default() += 1;
441        }
442        json!(counts)
443    }
444
445    pub fn summary(&self) -> Value {
446        json!({
447            "id": self.id, "workflow": self.workflow, "status": self.status, "start": self.start.node,
448            "steps": self.progress(), "tokens": self.tokens, "created": self.created, "updated": self.updated,
449            "finished": self.finished, "output": self.output, "error": self.error, "task": self.task, "principal": self.principal,
450        })
451    }
452}
453
454/// What the scheduler wants done next.
455#[derive(Debug, Clone, PartialEq)]
456pub enum Next {
457    /// Start these steps (all deps satisfied, `when` true).
458    Ready(Vec<String>),
459    /// Nothing ready but work is in flight / suspended.
460    Waiting,
461    /// The run is stalled: no ready step, nothing in flight, no finish reached.
462    Stalled,
463    /// The run is terminal already.
464    Terminal,
465}
466
467/// Compute the ready steps. `when` guards are evaluated over `data`; a false
468/// guard skips the step (durably) — hence `&mut RunState`.
469pub fn schedule(wf: &Workflow, run: &mut RunState, data: &Data) -> Result<Next, String> {
470    if run.status.is_terminal() {
471        return Ok(Next::Terminal);
472    }
473    let mut ready = Vec::new();
474    let mut in_flight = false;
475    let mut changed = true;
476    // Iterate to a fixpoint so a newly-skipped step lets its dependents proceed.
477    while changed {
478        changed = false;
479        for id in wf.topo_order() {
480            let step = &wf.steps[&id];
481            let st = run.steps.get(&id).cloned().unwrap_or_default();
482            match st.status {
483                StepStatus::Running => {
484                    in_flight = true;
485                    continue;
486                }
487                StepStatus::Suspended => {
488                    in_flight = true;
489                    continue;
490                }
491                s if s.is_terminal() => continue,
492                _ => {}
493            }
494            if ready.contains(&id) {
495                continue;
496            }
497            if st.forced {
498                ready.push(id.clone());
499                continue;
500            }
501            // An `on_timeout` target is reached by ROUTING only: dep-less by
502            // design (depending on the wait would also fire it on success),
503            // it stays parked until the timeout forces it.
504            if step.depends_on.is_empty()
505                && !step.is_start()
506                && wf
507                    .steps
508                    .values()
509                    .any(|s| s.field_str("on_timeout") == Some(id.as_str()))
510            {
511                continue;
512            }
513            // Transitive pruning. A step whose dependencies are ALL pruned can
514            // never run, so it is pruned too and the wave carries down the dead
515            // branch. One live dependency is enough to keep the step alive —
516            // that is the uneven-join case, and it is why this is not simply
517            // "any pruned dep prunes me".
518            let pruned_deps = step
519                .depends_on
520                .iter()
521                .filter(|d| {
522                    run.steps
523                        .get(*d)
524                        .is_some_and(|s| s.status == StepStatus::Pruned)
525                })
526                .count();
527            if !step.depends_on.is_empty() && pruned_deps == step.depends_on.len() {
528                run.end_step(&id, StepStatus::Pruned, None, None);
529                changed = true;
530                continue;
531            }
532            // A pruned dependency is not waited on: the live paths decide.
533            let deps_ok = step.depends_on.iter().all(|d| {
534                run.steps
535                    .get(d)
536                    .is_some_and(|s| s.status.is_satisfied() || s.status == StepStatus::Pruned)
537            });
538            let deps_failed = step.depends_on.iter().any(|d| {
539                run.steps.get(d).is_some_and(|s| {
540                    matches!(
541                        s.status,
542                        StepStatus::Failed | StepStatus::Cancelled | StepStatus::Timeout
543                    )
544                })
545            });
546            if deps_failed {
547                // A failed dependency that was not routed (on_error fail already
548                // failed the run) — treat like cancelled downstream.
549                continue;
550            }
551            if !deps_ok {
552                continue;
553            }
554            if let Some(w) = &step.when {
555                let expr = w.trim().trim_start_matches("CEL:").trim();
556                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
557                match crate::cel::eval_bool(expr, &vars) {
558                    Ok(true) => {}
559                    Ok(false) => {
560                        // Not taken, so nothing that depends only on it runs.
561                        run.end_step(&id, StepStatus::Pruned, None, None);
562                        changed = true;
563                        continue;
564                    }
565                    Err(e) => return Err(format!("step {id:?}: when: {e}")),
566                }
567            }
568            ready.push(id.clone());
569        }
570    }
571    if !ready.is_empty() {
572        return Ok(Next::Ready(ready));
573    }
574    if in_flight {
575        return Ok(Next::Waiting);
576    }
577    Ok(Next::Stalled)
578}
579
580/// Apply a failed step's `on_error` policy: returns the steps to schedule
581/// next (a `goto` target) or `Err(reason)` when the run must fail.
582pub fn route_failure(
583    wf: &Workflow,
584    run: &mut RunState,
585    step: &Step,
586    error: &str,
587) -> Result<Vec<String>, String> {
588    match &step.on_error {
589        OnError::Fail => Err(format!("step {:?} failed: {error}", step.id)),
590        OnError::Continue => {
591            // Downstream sees the step as satisfied-with-error: mark it done
592            // with an error output so `steps.<id>.error` is inspectable.
593            let st = run.steps.entry(step.id.clone()).or_default();
594            st.status = StepStatus::Done;
595            st.error = Some(error.to_string());
596            if st.output.is_none() {
597                st.output = Some(json!({"error": error}));
598            }
599            run.touch();
600            Ok(Vec::new())
601        }
602        OnError::Goto(target) => {
603            if !wf.steps.contains_key(target) {
604                return Err(format!(
605                    "step {:?}: on_error goto {target:?} does not exist",
606                    step.id
607                ));
608            }
609            // The recovery target runs even if its deps are not terminal.
610            let st = run.steps.entry(target.clone()).or_default();
611            st.status = StepStatus::Pending;
612            st.forced = true;
613            run.touch();
614            Ok(vec![target.clone()])
615        }
616    }
617}
618
619/// Whether the run's deadline passed.
620pub fn deadline_passed(run: &RunState) -> bool {
621    run.deadline_ms.is_some_and(|d| now_ms() >= d)
622}
623
624/// The `env` view (curated, secret-free) the templates see.
625/// The idempotency key for one step of one run: stable across retries and
626/// replays BY ARITHMETIC — it is derived from identity every attempt of the
627/// same logical operation already shares — and distinct across runs because
628/// run ids are. Hashed so the remote learns nothing: a raw `run.step` would
629/// leak ULID timestamps and internal step names to every API that logs its
630/// keys, which is the good instinct behind wanting keys random. Deterministic
631/// derivation gets the opacity without the persistence, and without the
632/// crash-window a mint-then-store scheme has to defend forever.
633///
634/// Anything time-based or random here would be WRONG: specs re-render on every
635/// attempt, so a fresh value per attempt is precisely the duplicate-charge the
636/// mechanism exists to prevent.
637pub fn idempotency_key(run_id: &str, step_id: &str) -> String {
638    let h = crate::sha::sha256_hex(format!("{run_id}.{step_id}").as_bytes());
639    h[..32].to_string()
640}
641
642pub fn env_view(
643    instance: &str,
644    run_id: &str,
645    instruction: Option<&str>,
646    prompt: Option<&str>,
647) -> Value {
648    json!({
649        "instance": instance,
650        "run": run_id,
651        "ts": now_ms(),
652        "instruction": instruction,
653        // The one-shot task (`--prompt`); the sugar workflow reads it.
654        "prompt": prompt,
655    })
656}
657
658/// Render a step's spec against the run data (every field, recursively).
659pub fn render_spec(step: &Step, data: &Data) -> Result<Map<String, Value>, String> {
660    let mut out = Map::new();
661    for (k, v) in &step.spec {
662        if super::model::is_raw_field(&step.kind, k) {
663            out.insert(k.clone(), v.clone());
664            continue;
665        }
666        out.insert(
667            k.clone(),
668            template::render(v, data).map_err(|e| format!("step {:?}: {k}: {e}", step.id))?,
669        );
670    }
671    Ok(out)
672}
673
674// These tests drive workflows with `CEL:` when-clauses, so the whole module
675// needs the `cel` feature (a default build evaluates CEL fail-closed).
676#[cfg(all(test, feature = "cel"))]
677mod tests {
678    use super::*;
679    use crate::engine::model::parse_workflow;
680
681    /// The whole point of the key: RETRIES of one step share it, different
682    /// operations do not, and the remote learns nothing from it.
683    #[test]
684    fn idempotency_keys_are_stable_per_step_and_opaque() {
685        let a = idempotency_key("run-01ABC", "charge");
686        assert_eq!(
687            a,
688            idempotency_key("run-01ABC", "charge"),
689            "a retry carries the SAME key"
690        );
691        assert_ne!(
692            a,
693            idempotency_key("run-01ABC", "refund"),
694            "another step is another operation"
695        );
696        assert_ne!(
697            a,
698            idempotency_key("run-02XYZ", "charge"),
699            "another run is another operation"
700        );
701        // Scoped ids make fan-out iterations distinct operations automatically.
702        assert_ne!(
703            idempotency_key("r", "each[0].call"),
704            idempotency_key("r", "each[1].call")
705        );
706        assert_eq!(a.len(), 32);
707        assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {a}");
708        assert!(
709            !a.contains("run-01ABC") && !a.contains("charge"),
710            "leaks nothing"
711        );
712    }
713
714    fn start_at(node: &str) -> Start {
715        Start {
716            node: node.into(),
717            payload: json!({}),
718            ts: 0,
719        }
720    }
721
722    /// The branch nobody chose must not run, and neither must its TAIL — which
723    /// it did before `Pruned` existed, because `Skipped` satisfies dependents.
724    /// The join is why this cannot be "any pruned dep prunes me": `fin` has one
725    /// pruned parent and one live one, and must still run.
726    #[test]
727    fn an_untaken_branch_prunes_its_tail_but_not_a_live_join() {
728        let w = parse_workflow(&json!({
729            "name": "w", "steps": {
730                "go":  {"kind": "once"},
731                "la":  {"kind": "noop", "depends_on": ["go"]},
732                "ra":  {"kind": "noop", "depends_on": ["go"]},
733                "la2": {"kind": "noop", "depends_on": ["la"]},
734                "ra2": {"kind": "noop", "depends_on": ["ra"]},
735                "fin": {"kind": "finish", "depends_on": ["la2", "ra2"], "status": "completed"}
736            }
737        }))
738        .unwrap();
739        let mut run = RunState::new("r", &w, start_at("go"), json!({}));
740        run.end_step("ra", StepStatus::Pruned, None, None);
741        run.end_step("la", StepStatus::Done, None, None);
742        let data = run.data(env_view("i", "r", None, None), json!({}));
743        let _ = schedule(&w, &mut run, &data).unwrap();
744        assert_eq!(
745            run.steps["ra2"].status,
746            StepStatus::Pruned,
747            "the dead branch's tail must be pruned, not run"
748        );
749
750        run.end_step("la2", StepStatus::Done, None, None);
751        let data = run.data(env_view("i", "r", None, None), json!({}));
752        match schedule(&w, &mut run, &data).unwrap() {
753            Next::Ready(r) => assert!(
754                r.iter().any(|s| s == "fin"),
755                "a join with one pruned and one live parent must run, got {r:?}"
756            ),
757            other => panic!("expected fin ready, got {other:?}"),
758        }
759    }
760
761    /// Sibling start nodes stay `Skipped`, which SATISFIES dependents — several
762    /// triggers, one fires, the graph below still runs. Pruning must not have
763    /// swallowed that distinction.
764    #[test]
765    fn sibling_start_nodes_still_satisfy_their_dependents() {
766        let w = parse_workflow(&json!({
767            "name": "w", "steps": {
768                "a":    {"kind": "once"},
769                "b":    {"kind": "manual"},
770                "work": {"kind": "noop", "depends_on": ["a", "b"]},
771                "fin":  {"kind": "finish", "depends_on": ["work"], "status": "completed"}
772            }
773        }))
774        .unwrap();
775        let mut run = RunState::new("r", &w, start_at("a"), json!({}));
776        assert_eq!(run.steps["b"].status, StepStatus::Skipped);
777        let data = run.data(env_view("i", "r", None, None), json!({}));
778        match schedule(&w, &mut run, &data).unwrap() {
779            Next::Ready(r) => assert!(
780                r.iter().any(|s| s == "work"),
781                "a step below several start nodes must run when one fired, got {r:?}"
782            ),
783            other => panic!("expected work ready, got {other:?}"),
784        }
785    }
786
787    fn wf() -> Workflow {
788        parse_workflow(&json!({
789            "name": "w", "steps": {
790                "s": {"kind": "once"},
791                "a": {"kind": "noop", "depends_on": ["s"]},
792                "b": {"kind": "noop", "depends_on": ["s"], "when": "CEL: inputs.go == true"},
793                "c": {"kind": "noop", "depends_on": ["a", "b"], "on_error": "goto:fix"},
794                "fix": {"kind": "noop", "depends_on": ["c"]},
795                "f": {"kind": "finish", "depends_on": ["c"], "status": "completed", "output": "{{vars.x | none}}"}
796            }
797        }))
798        .unwrap()
799    }
800
801    #[cfg(feature = "cel")]
802    #[test]
803    fn scheduling_guards_failures_and_terminal_states() {
804        let w = wf();
805        let mut run = RunState::new(
806            "r1",
807            &w,
808            Start {
809                node: "s".into(),
810                payload: json!({"p": 1}),
811                ts: 0,
812            },
813            json!({"go": false}),
814        );
815        assert_eq!(run.steps["s"].status, StepStatus::Done);
816        assert_eq!(run.steps["s"].output, Some(json!({"p": 1})));
817        let data = run.data(env_view("i", "r1", None, None), json!({}));
818        // a is ready; b's guard is false → skipped; c waits on a.
819        assert_eq!(
820            schedule(&w, &mut run, &data).unwrap(),
821            Next::Ready(vec!["a".to_string()])
822        );
823        // A false guard PRUNES: "do not do this" now also means "do not do the
824        // things that exist only because of this". `c` still runs below, because
825        // its other parent `a` is live — pruning follows dead paths, not steps.
826        assert_eq!(run.steps["b"].status, StepStatus::Pruned);
827        run.begin_step("a");
828        let data = run.data(env_view("i", "r1", None, None), json!({}));
829        assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Waiting);
830        run.end_step("a", StepStatus::Done, Some(json!("A")), None);
831        let data = run.data(env_view("i", "r1", None, None), json!({}));
832        assert_eq!(
833            schedule(&w, &mut run, &data).unwrap(),
834            Next::Ready(vec!["c".to_string()])
835        );
836        // c fails → goto fix.
837        run.begin_step("c");
838        run.end_step("c", StepStatus::Failed, None, Some("boom".into()));
839        let next = route_failure(&w, &mut run, w.step("c").unwrap(), "boom").unwrap();
840        assert_eq!(next, vec!["fix".to_string()]);
841        run.begin_step("fix");
842        run.end_step("fix", StepStatus::Done, None, None);
843        // f depends on c which FAILED (not satisfied) → nothing ready, nothing in flight → stalled.
844        let data = run.data(env_view("i", "r1", None, None), json!({}));
845        assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Stalled);
846        run.finish(RunStatus::Stalled, None, Some("stalled".into()));
847        assert!(run.status.is_terminal());
848        let data = run.data(env_view("i", "r1", None, None), json!({}));
849        assert_eq!(schedule(&w, &mut run, &data).unwrap(), Next::Terminal);
850        // Continue policy marks done-with-error.
851        let mut run2 = RunState::new(
852            "r2",
853            &w,
854            Start {
855                node: "s".into(),
856                payload: json!({}),
857                ts: 0,
858            },
859            json!({"go": true}),
860        );
861        let mut c = w.step("c").unwrap().clone();
862        c.on_error = OnError::Continue;
863        run2.begin_step("c");
864        run2.end_step("c", StepStatus::Failed, None, Some("e".into()));
865        assert!(route_failure(&w, &mut run2, &c, "e").unwrap().is_empty());
866        assert_eq!(run2.steps["c"].status, StepStatus::Done);
867        assert_eq!(run2.steps["c"].error.as_deref(), Some("e"));
868        // Fail policy errors.
869        let mut a = w.step("a").unwrap().clone();
870        a.on_error = OnError::Fail;
871        assert!(route_failure(&w, &mut run2, &a, "e").is_err());
872    }
873
874    #[test]
875    fn vars_reducers_and_serialization() {
876        let w = wf();
877        let mut run = RunState::new("r", &w, Start::default(), json!({}));
878        run.write_var("l", json!([1]), "overwrite");
879        run.write_var("l", json!(2), "append");
880        run.write_var("l", json!([3, 4]), "append");
881        assert_eq!(run.vars["l"], json!([1, 2, 3, 4]));
882        run.write_var("l", json!([4, 5]), "union");
883        assert_eq!(run.vars["l"], json!([1, 2, 3, 4, 5]));
884        run.write_var("o", json!({"a": 1}), "overwrite");
885        run.write_var("o", json!({"b": 2}), "merge");
886        assert_eq!(run.vars["o"], json!({"a": 1, "b": 2}));
887        run.write_var("o", json!(7), "overwrite");
888        assert_eq!(run.vars["o"], json!(7));
889        let v = serde_json::to_value(&run).unwrap();
890        let back: RunState = serde_json::from_value(v).unwrap();
891        assert_eq!(back.vars, run.vars);
892        assert!(!back.dirty);
893        assert_eq!(back.summary()["workflow"], json!("w"));
894        // A rendered spec.
895        let data = run.data(
896            env_view("inst", "r", Some("brief"), None),
897            json!({"k": "v"}),
898        );
899        let mut s = w.step("f").unwrap().clone();
900        s.spec
901            .insert("extra".into(), json!("{{env.instruction}}/{{memory.k}}"));
902        let rendered = render_spec(&s, &data).unwrap();
903        assert_eq!(rendered["output"], json!("none"));
904        assert_eq!(rendered["extra"], json!("brief/v"));
905        assert!(!deadline_passed(&run));
906    }
907}