Skip to main content

agentd/runtime/
steps.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Runs and steps**: arming start nodes,
3//! turning start events into durable runs, scheduling ready steps every tick,
4//! executing the step kinds (data steps in-loop, MCP calls on executor
5//! threads, `agent`/`think` in turn workers, `sleep` on durable timers,
6//! `finish` closing the run), retries + `on_error` routing, and the
7//! `workflow.*` tools.
8
9use super::children::ChildKind;
10use super::events::kinds;
11use super::reactor::{PendingKind, Runtime, Target};
12use super::tools::{ToolCaller, ToolOutcome};
13use crate::config::v2::substitute_config_vars;
14use crate::context::Msg;
15use crate::engine::model::{OnError, Step, Workflow, parse_workflow};
16use crate::engine::run::{
17    self, Next, RunState, RunStatus, Start, StepStatus, env_view, render_spec,
18};
19use crate::engine::template;
20use crate::governor::Admission;
21use crate::registry::Caller;
22use crate::state::{InboxEvent, Kind, now_ms, ulid};
23use crate::subagent::protocol::{TurnKind, TurnResult, TurnSpec};
24use serde_json::{Map, Value, json};
25use std::collections::BTreeMap;
26
27/// The memory key prefix runtime-created workflow definitions are stored under.
28const WORKFLOW_DEF_PREFIX: &str = "_workflows/";
29
30impl Runtime {
31    // ---- definitions -----------------------------------------------------------
32
33    /// The breaker policy a step actually runs under: its own `breaker:` wins;
34    /// an `mcp.tool` step against a server that names a catalog `service:`
35    /// otherwise inherits that entry's `breaker:` default. The admission gate
36    /// and the outcome recorder both call this, so they can never disagree
37    /// about which policy a given step is being judged by.
38    pub(crate) fn effective_breaker(
39        &self,
40        step: &crate::engine::model::Step,
41    ) -> Option<super::breaker::Config> {
42        if let Some(cfg) = super::breaker::Config::of(step.spec.get("breaker")) {
43            return Some(cfg);
44        }
45        if step.kind != "mcp.tool" {
46            return None;
47        }
48        let server = step.field_str("server")?;
49        let svc = self
50            .settings
51            .mcp
52            .servers
53            .iter()
54            .find(|s| s.name == server)
55            .and_then(|s| s.service.as_ref())?;
56        super::breaker::Config::of(self.settings.services.get(svc)?.breaker.as_ref())
57    }
58
59    /// Resolve a parsed definition's durability class against the store
60    /// default (`store.durability.work`): an explicit `durable:` wins; absent,
61    /// `ephemeral` deployments run everything memory-only.
62    pub(crate) fn fill_durable_default(&self, w: &mut crate::engine::model::Workflow) {
63        if w.durable.is_none() {
64            w.durable = Some(self.work_durable_default());
65        }
66    }
67
68    /// Load the configured workflows (inline / file / uri) plus the
69    /// runtime-created ones from the store. Errors are collected rather than
70    /// returned on the first failure, so one bad definition is refused with a
71    /// message instead of hiding the rest.
72    pub(crate) fn load_workflows(&mut self) -> Result<(), Vec<String>> {
73        let mut errs = Vec::new();
74        // A `{dir}` entry expands into one entry per matching file BEFORE
75        // resolution, so everything downstream — parsing, naming, the duplicate
76        // check — sees a plain list of documents and needs no directory case.
77        let mut docs: Vec<Value> = Vec::new();
78        for doc in self.settings.workflows.clone() {
79            // The ENTRY fold runs here, BEFORE the dir expansion, because a
80            // `dir:` is consumed by that expansion and would never reach the
81            // per-document fold below — `{{config.wf_dir}}` went to the
82            // filesystem verbatim and failed as "not a directory". `file:` and
83            // `url:` are folded again below (idempotent: a folded string has no
84            // tokens left), which keeps the inline case reporting an unresolved
85            // reference exactly once.
86            let mut doc = doc;
87            if doc.get("steps").is_none() {
88                substitute_config_vars(&mut doc, &self.settings.vars, "workflow entry", &mut errs);
89            }
90            match doc.get("dir").and_then(Value::as_str) {
91                None => docs.push(doc),
92                Some(dir) => {
93                    let pattern = doc
94                        .get("glob")
95                        .and_then(Value::as_str)
96                        .unwrap_or("*.yaml,*.yml,*.json");
97                    match expand_dir(dir, pattern) {
98                        Ok(paths) if paths.is_empty() => {
99                            // Silence here would mean a schedule that never
100                            // fires and no way to tell why.
101                            errs.push(format!("workflow dir {dir}: no file matched {pattern:?}"));
102                        }
103                        Ok(paths) => {
104                            for path in paths {
105                                let mut d = json!({"file": path});
106                                if let Some(a) = doc.get("armed") {
107                                    d["armed"] = a.clone();
108                                }
109                                docs.push(d);
110                            }
111                        }
112                        Err(e) => errs.push(format!("workflow dir {dir}: {e}")),
113                    }
114                }
115            }
116        }
117        for doc in docs {
118            // `{{config.*}}` folds in at load, in two passes: the ENTRY first —
119            // so a var can sit in a `file:`, `url:` or `dir:` reference and in
120            // the headers that fetch it — and the RESOLVED document after, so a
121            // definition arriving from a file or URL is treated exactly like an
122            // inline one. Folding here (rather than at render time) puts the
123            // substituted values in the definition hash: a var change is a
124            // definition change, and in-flight runs stay pinned to what they
125            // started with.
126            let mut doc = doc;
127            // The entry pass covers only REFERENCE entries (a var in a `file:`,
128            // `url:` or the headers that fetch it). An inline definition skips
129            // it — the resolved pass below sees the same document, and running
130            // both would report every unresolved reference twice.
131            if doc.get("steps").is_none() {
132                substitute_config_vars(&mut doc, &self.settings.vars, "workflow entry", &mut errs);
133            }
134            let resolved = match (
135                doc.get("file").and_then(Value::as_str),
136                doc.get("uri").and_then(Value::as_str),
137            ) {
138                (Some(path), _) => match std::fs::read_to_string(path)
139                    .map_err(|e| e.to_string())
140                    .and_then(|t| {
141                        crate::config::file::parse_document(
142                            &t,
143                            crate::config::file::Format::detect(
144                                Some(std::path::Path::new(path)),
145                                &t,
146                            ),
147                        )
148                    }) {
149                    Ok(mut d) => {
150                        if d.get("name").is_none()
151                            && let Some(n) = doc.get("name")
152                        {
153                            d["name"] = n.clone();
154                        }
155                        d
156                    }
157                    Err(e) => {
158                        errs.push(format!("workflow file {path}: {e}"));
159                        continue;
160                    }
161                },
162                // A `url:` is fetched over HTTP(S), with operator-declared
163                // headers — the shape people already have for a definitions
164                // service or a raw git URL. Distinct from `uri:`, which is an
165                // MCP resource: both name "somewhere else", but only one of
166                // them makes the daemon dial.
167                (None, None) if doc.get("url").is_some() => {
168                    let url = doc["url"].as_str().unwrap_or_default().to_string();
169                    match self.fetch_workflow_url(&doc, &url) {
170                        Ok(mut d) => {
171                            if d.get("name").is_none()
172                                && let Some(n) = doc.get("name")
173                            {
174                                d["name"] = n.clone();
175                            }
176                            d
177                        }
178                        Err(e) => {
179                            errs.push(format!("workflow url {url}: {e}"));
180                            continue;
181                        }
182                    }
183                }
184                (None, Some(uri)) => match self.read_resource_any(uri) {
185                    Ok(text) => match crate::config::file::parse_document(
186                        &text,
187                        crate::config::file::Format::detect(Some(std::path::Path::new(uri)), &text),
188                    ) {
189                        Ok(mut d) => {
190                            if d.get("name").is_none()
191                                && let Some(n) = doc.get("name")
192                            {
193                                d["name"] = n.clone();
194                            }
195                            d
196                        }
197                        Err(e) => {
198                            errs.push(format!("workflow uri {uri}: {e}"));
199                            continue;
200                        }
201                    },
202                    Err(e) => {
203                        errs.push(format!("workflow uri {uri}: {e}"));
204                        continue;
205                    }
206                },
207                _ => doc.clone(),
208            };
209            let mut resolved = resolved;
210            substitute_config_vars(&mut resolved, &self.settings.vars, "workflow", &mut errs);
211            match parse_workflow(&resolved) {
212                Ok(mut w) => {
213                    self.fill_durable_default(&mut w);
214                    self.log.info("workflow.loaded", json!({"name": w.name, "hash": &w.hash[..12], "steps": w.steps.len(), "durable": w.durable, "starts": w.start_steps().iter().map(|s| s.kind.clone()).collect::<Vec<_>>()}));
215                    self.workflows
216                        .insert(w.name.clone(), std::sync::Arc::new(w));
217                }
218                Err(e) => errs.extend(e),
219            }
220        }
221        // Runtime-created definitions (durable under memory/_workflows/<name>).
222        if let Ok(list) = self.durable.list(Kind::Memory) {
223            for ks in list {
224                let Some((_, id)) = crate::store::parse_key(
225                    self.durable.prefix(),
226                    self.durable.instance(),
227                    &ks.key,
228                ) else {
229                    continue;
230                };
231                if let Some(name) = id.strip_prefix(WORKFLOW_DEF_PREFIX)
232                    && !self.workflows.contains_key(name)
233                    && let Ok(Some(env)) = self.durable.get(Kind::Memory, id)
234                    && let Some(def) = env.state.get("value")
235                {
236                    match parse_workflow(def) {
237                        Ok(mut w) => {
238                            self.fill_durable_default(&mut w);
239                            self.log.info(
240                                "workflow.loaded",
241                                json!({"name": w.name, "source": "store"}),
242                            );
243                            self.workflows
244                                .insert(w.name.clone(), std::sync::Arc::new(w));
245                        }
246                        Err(e) => self.log.warn(
247                            "workflow.stored.invalid",
248                            json!({"name": name, "errors": e}),
249                        ),
250                    }
251                }
252            }
253        }
254        // Validate tool/server references against the registry.
255        for w in self.workflows.values() {
256            for s in w.steps.values() {
257                match s.kind.as_str() {
258                    "tool" => {
259                        if let Some(n) = s.field_str("name")
260                            && !self.registry.allowed(&Caller::Workflow, n)
261                        {
262                            errs.push(format!("workflow {:?} step {:?}: tool {n:?} is unknown, disabled or not granted to workflows", w.name, s.id));
263                        }
264                    }
265                    "mcp.tool" => {
266                        if let Some(srv) = s.field_str("server")
267                            && !self.mcp.contains_key(srv)
268                        {
269                            errs.push(format!(
270                                "workflow {:?} step {:?}: mcp server {srv:?} is not connected",
271                                w.name, s.id
272                            ));
273                        }
274                    }
275                    k if (k.starts_with("memory.")
276                        || k.starts_with("artifact.")
277                        || k.starts_with("knowledge.")
278                        || k.starts_with("search."))
279                        && !self.registry.allowed(&Caller::Workflow, k) =>
280                    {
281                        errs.push(format!("workflow {:?} step {:?}: {k} is unavailable (map it with tools.overrides or configure its server)", w.name, s.id));
282                    }
283                    _ => {}
284                }
285            }
286        }
287        // Mixed durability is legal but has one sharp edge worth a loud line:
288        // a DURABLE parent step waiting on a NON-durable child run resumes
289        // after a restart to find the run gone (the wait fails with "run does
290        // not exist"). Say so at load, where the shape is still a choice.
291        for w in self.workflows.values() {
292            if w.durable == Some(false) {
293                continue;
294            }
295            for st in w.steps.values() {
296                if st.kind == "workflow"
297                    && st.field_str("mode").unwrap_or("sync") != "detached"
298                    && let Some(target) = st.field_str("name")
299                    && self
300                        .workflows
301                        .get(target)
302                        .is_some_and(|t| t.durable == Some(false))
303                {
304                    self.log.warn(
305                        "workflow.durability_mix",
306                        json!({"workflow": w.name, "step": st.id, "child": target,
307                               "note": "a durable parent waits on a non-durable child; after a restart the wait fails (the child run does not survive)"}),
308                    );
309                }
310            }
311        }
312        // Streams are fail-closed at load: an `emit` or `stream` node naming an
313        // undeclared stream is a config error now, not a step failure later.
314        for w in self.workflows.values() {
315            for st in w.steps.values() {
316                if matches!(st.kind.as_str(), "emit" | "stream")
317                    && let Some(name) = st.field_str("stream")
318                    && !self.settings.streams.contains_key(name)
319                {
320                    errs.push(format!(
321                        "workflow {:?} step {:?}: stream {name:?} is not declared under `streams:`",
322                        w.name, st.id
323                    ));
324                }
325            }
326        }
327        if errs.is_empty() { Ok(()) } else { Err(errs) }
328    }
329
330    /// Fetch a workflow definition over HTTP(S).
331    ///
332    /// Startup-time and fail-closed: an unreachable definitions service is a
333    /// daemon that would otherwise come up with a schedule silently missing, so
334    /// it refuses to start and says which URL. That matches how a required MCP
335    /// server behaves and is the safer half of the trade.
336    ///
337    /// Headers are operator-declared and resolve `{{secret:…}}` like every
338    /// other credential, so a token never sits in the config file. The URL is
339    /// SSRF-guarded like any other outbound request — an operator-chosen URL is
340    /// far more trustworthy than a model-chosen one, but `allow_private` is
341    /// still an explicit decision rather than an assumption.
342    fn fetch_workflow_url(&self, doc: &Value, url: &str) -> Result<Value, String> {
343        let headers: Vec<(String, String)> = doc
344            .get("headers")
345            .and_then(Value::as_object)
346            .map(|m| {
347                m.iter()
348                    .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
349                    .collect()
350            })
351            .unwrap_or_default();
352        let headers =
353            crate::mcp::auth::resolve_headers(&headers).map_err(|e| format!("headers: {e}"))?;
354        let timeout = doc
355            .get("timeout")
356            .and_then(Value::as_str)
357            .and_then(|t| crate::config::parse_duration(t).ok())
358            .unwrap_or(std::time::Duration::from_secs(20));
359        let allow_private = doc
360            .get("allow_private")
361            .and_then(Value::as_bool)
362            .unwrap_or(false);
363        let text = crate::runtime::http_node::fetch_text(url, &headers, timeout, allow_private)?;
364        crate::config::file::parse_document(
365            &text,
366            crate::config::file::Format::detect(Some(std::path::Path::new(url)), &text),
367        )
368    }
369
370    /// Read a resource URI (`mcp://<server>/<uri>` or a URI a connected server lists).
371    pub(crate) fn read_resource_any(&self, uri: &str) -> Result<String, String> {
372        if let Some(rest) = uri.strip_prefix("mcp://") {
373            let (server, res) = rest
374                .split_once('/')
375                .ok_or("mcp:// uri needs <server>/<resource-uri>")?;
376            let c = self
377                .mcp
378                .get(server)
379                .ok_or_else(|| format!("mcp server {server:?} is not connected"))?;
380            return c
381                .read_resource(res)
382                .map(|r| r.text())
383                .map_err(|e| e.to_string());
384        }
385        let mut last = String::from("no connected server serves it");
386        for c in self.mcp.values() {
387            match c.read_resource(uri) {
388                Ok(r) => return Ok(r.text()),
389                Err(e) => last = e.to_string(),
390            }
391        }
392        Err(last)
393    }
394
395    /// Arm start nodes. `once` fires immediately unless a live run of the
396    /// workflow came back from the store (`policy: ensure`, the default), which
397    /// keeps a restart from starting a second copy of work already in flight;
398    /// `policy: always` fires regardless.
399    pub(crate) fn arm_workflows(&mut self) {
400        let names: Vec<String> = self.workflows.keys().cloned().collect();
401        for name in names {
402            let Some(w) = self.workflows.get(&name) else {
403                continue;
404            };
405            if !w.armed {
406                continue;
407            }
408            let starts: Vec<(String, String, Map<String, Value>)> = w
409                .start_steps()
410                .iter()
411                .map(|s| (s.id.clone(), s.kind.clone(), s.spec.clone()))
412                .collect();
413            for (id, kind, spec) in starts {
414                match kind.as_str() {
415                    "once" => {
416                        let policy = spec
417                            .get("policy")
418                            .and_then(Value::as_str)
419                            .unwrap_or("ensure");
420                        let live = self
421                            .runs
422                            .values()
423                            .any(|r| r.workflow == name && !r.status.is_terminal());
424                        let ever = self
425                            .runs
426                            .values()
427                            .any(|r| r.workflow == name && r.start.node == id);
428                        // A replayed (still pending) firing counts too — never fire twice.
429                        let pending = self.inbox_queue.iter().any(|e| {
430                            e.kind == kinds::START_FIRED
431                                && e.payload["workflow"] == name.as_str()
432                                && e.payload["node"] == id.as_str()
433                        });
434                        if policy == "ensure" && (live || ever || pending) {
435                            self.log.info("start.once.skipped", json!({"workflow": name, "node": id, "live": live, "pending": pending}));
436                            continue;
437                        }
438                        let inputs = spec.get("inputs").cloned().unwrap_or(json!({}));
439                        // A `once` start is autonomous work like any other
440                        // trigger, so it is attributed the same way.
441                        let acting = self.settings.identity.autonomous_id().to_string();
442                        let _ = self.accept_event(kinds::START_FIRED, Some(acting), json!({"workflow": name, "node": id, "payload": {"fired_at": now_ms()}, "inputs": inputs}));
443                    }
444                    "manual" => {}
445                    // Long-lived starts are armed by arm_long_lived_starts.
446                    _ => {}
447                }
448            }
449        }
450    }
451
452    /// A start event → a run. Returns `true` when the event is consumed.
453    pub(crate) fn on_start_event(&mut self, ev: &InboxEvent) -> bool {
454        let name = ev.payload["workflow"].as_str().unwrap_or("").to_string();
455        let Some(w) = self.workflows.get(&name).cloned() else {
456            self.log.warn(
457                "start.unknown_workflow",
458                json!({"inbox_event": ev.id, "workflow": name}),
459            );
460            return true;
461        };
462        let node = ev.payload["node"]
463            .as_str()
464            .map(str::to_string)
465            .unwrap_or_else(|| default_start(&w).unwrap_or_default());
466        // Concurrency: a new run must fit both the workflow's own `max_runs`
467        // and the instance-wide `limits.max_runs`; whichever binds first
468        // decides the `on_overflow` outcome.
469        //
470        // `scope: key` counts only the runs about the SAME THING, which is the
471        // difference between a queue and a lock: `max_runs: 1` under
472        // `scope: workflow` serialises every customer behind one run, so
473        // per-entity ordering used to mean one workflow definition per entity.
474        // A firing whose key did not render counts under the workflow scope —
475        // sharing an "unkeyed" bucket with every other such firing would
476        // silently serialise unrelated work.
477        let this_key = ev.payload["key"].as_str();
478        let keyed = w.concurrency.scope == crate::engine::model::ConcurrencyScope::Key
479            && this_key.is_some();
480        let live = self
481            .runs
482            .values()
483            .filter(|r| r.workflow == name && !r.status.is_terminal())
484            .filter(|r| !keyed || r.key.as_deref() == this_key)
485            .count() as u32;
486        let global_live = self
487            .runs
488            .values()
489            .filter(|r| !r.status.is_terminal())
490            .count() as u32;
491        if live >= w.concurrency.max_runs
492            || global_live >= self.settings.limits.max_runs.unwrap_or(8)
493        {
494            match w.concurrency.on_overflow {
495                crate::engine::model::OnOverflow::Queue => {
496                    // Keep the event pending; it is retried on a LATER tick.
497                    // Nothing this tick can relieve the cap — only a live run
498                    // reaching a terminal status in `schedule_runs` does, and
499                    // that step has not run yet — so this must not be re-offered
500                    // now. `process_inbox` drains a snapshot for exactly that
501                    // reason: this push lands on the next tick's queue.
502                    self.inbox_queue.push_back(ev.clone());
503                    return false;
504                }
505                crate::engine::model::OnOverflow::Drop => {
506                    self.log.warn(
507                        "run.dropped",
508                        json!({"workflow": name, "reason": "concurrency"}),
509                    );
510                    return true;
511                }
512                crate::engine::model::OnOverflow::Replace => {
513                    if let Some(oldest) = self
514                        .runs
515                        .values()
516                        .filter(|r| r.workflow == name && !r.status.is_terminal())
517                        .min_by_key(|r| r.created)
518                        .map(|r| r.id.clone())
519                    {
520                        self.cancel_run(&oldest, "replaced by a newer run");
521                    }
522                }
523            }
524        }
525        // Inputs.
526        let inputs = ev.payload.get("inputs").cloned().unwrap_or(json!({}));
527        if let Some(schema) = &w.inputs_schema
528            && let Err(e) = crate::jsonschema::validate(schema, &inputs)
529        {
530            self.log
531                .warn("run.inputs.invalid", json!({"workflow": name, "errors": e}));
532            return true;
533        }
534        // A2A pre-generates the run id so its task can link before the run starts.
535        let run_id = ev
536            .payload
537            .get("run_id")
538            .and_then(Value::as_str)
539            .map(str::to_string)
540            .unwrap_or_else(|| format!("{}-{}", name, ulid::new()));
541        // The definition this run starts under survives us durably: a restart
542        // that also changed or removed the workflow still finishes this run.
543        self.ensure_pin(&w);
544        let mut run = RunState::new(
545            &run_id,
546            &w,
547            Start {
548                node: node.clone(),
549                payload: ev.payload.get("payload").cloned().unwrap_or(Value::Null),
550                ts: now_ms(),
551            },
552            inputs,
553        );
554        run.principal = ev.principal.clone();
555        run.parent = ev.payload.get("parent").cloned().filter(|p| !p.is_null());
556        run.conversation = ev
557            .payload
558            .get("conversation")
559            .and_then(Value::as_str)
560            .map(str::to_string);
561        run.task = ev
562            .payload
563            .get("task")
564            .and_then(Value::as_str)
565            .map(str::to_string);
566        // A run inherits the message-hop depth of whatever asked for it, so a
567        // `message` inside it extends that chain rather than starting a fresh
568        // one. Triggers carry nothing and so start at 0.
569        run.msg_depth = ev.payload["msg_depth"].as_u64().unwrap_or(0) as u32;
570        run.key = ev.payload["key"].as_str().map(str::to_string);
571        // Durable before anything runs — unless the workflow opted out of the
572        // class entirely (`durable: false`): a memory-only run writes nothing,
573        // here or at any checkpoint.
574        if run.durable
575            && let Err(e) = self.durable.put(
576                Kind::Run,
577                &run_id,
578                serde_json::to_value(&run).unwrap_or(Value::Null),
579                Some(w.hash.clone()),
580            )
581        {
582            self.log.error(
583                "run.create.fail",
584                json!({"workflow": name, "err": e.to_string()}),
585            );
586            // The event stays pending in the DURABLE inbox (only a consumed
587            // start is marked done), so dropping it from the in-memory queue
588            // here would make the start silently vanish until a restart
589            // replays it. Requeue it like an overflow: the next tick retries.
590            self.inbox_queue.push_back(ev.clone());
591            return false;
592        }
593        run.dirty = false;
594        // The actor is on the line: "every effect names the human or the
595        // schedule that caused it" is only true if you can read it back.
596        self.log.info(
597            "run.start",
598            json!({"run": run_id, "workflow": name, "node": node, "inbox_event": ev.id,
599                   "acting_for": run.principal, "key": run.key}),
600        );
601        self.counters.runs_started += 1;
602        crate::obs::metrics::record_run_started();
603        if node_kind(&w, &node) == Some("once") && self.job_shape {
604            self.job_runs.push(run_id.clone());
605        }
606        // Answer a `workflow.run` waiter that asked for the id.
607        if ev.kind == kinds::WORKFLOW_RUN
608            && let Some(req) = ev.payload.get("request").and_then(Value::as_object)
609        {
610            let target = match (
611                req.get("node").and_then(Value::as_u64),
612                req.get("req").and_then(Value::as_u64),
613                req.get("run").and_then(Value::as_str),
614                req.get("step").and_then(Value::as_str),
615            ) {
616                (Some(n), Some(r), _, _) => {
617                    Some(Target::Child(crate::supervisor::tree::NodeId(n), r))
618                }
619                (None, None, Some(r), Some(s)) => Some(Target::Step(r.to_string(), s.to_string())),
620                _ => None,
621            };
622            if let Some(t) = target {
623                if req.get("wait").and_then(Value::as_bool).unwrap_or(false) {
624                    let deadline = now_ms()
625                        + req
626                            .get("timeout_ms")
627                            .and_then(Value::as_u64)
628                            .unwrap_or(3_600_000);
629                    self.push_pending(super::reactor::PendingTool {
630                        target: t,
631                        name: "workflow.run".into(),
632                        kind: PendingKind::Run {
633                            run: run_id.clone(),
634                            deadline_ms: deadline,
635                        },
636                        started_ms: now_ms(),
637                    });
638                } else {
639                    self.reply(
640                        &t,
641                        json!({"run": run_id, "status": "running", "workflow": name}),
642                        false,
643                    );
644                }
645            }
646        }
647        self.runs.insert(run_id, run);
648        true
649    }
650
651    // ---- scheduling ------------------------------------------------------------
652
653    /// Every tick: advance every live run.
654    pub(crate) fn schedule_runs(&mut self) {
655        if self.paused {
656            return; // operator hold (a2a.pause) — steps park until resume
657        }
658        // Higher-priority runs schedule first each tick, so under contention
659        // (fan-out slots, per-tick capacity) their ready steps win. Stable
660        // within a priority: BTreeMap order = name, then creation (ULID).
661        let mut ids: Vec<(std::cmp::Reverse<crate::engine::model::Priority>, String)> = self
662            .runs
663            .iter()
664            .filter(|(_, r)| !r.status.is_terminal() && r.status != RunStatus::Paused)
665            .map(|(id, r)| {
666                let pr = self
667                    .workflows
668                    .get(&r.workflow)
669                    .map(|w| w.priority)
670                    .unwrap_or_default();
671                (std::cmp::Reverse(pr), id.clone())
672            })
673            .collect();
674        ids.sort_by_key(|a| a.0);
675        for (_, id) in ids {
676            self.schedule_run(&id);
677        }
678    }
679
680    /// [`Self::definition_for_run`] addressed by `(name, hash)` — for callers
681    /// that already hold the run record.
682    pub(crate) fn definition_for_run_ref(&self, workflow: &str, hash: &str) -> Option<&Workflow> {
683        if let Some(w) = self.workflows.get(workflow)
684            && w.hash == hash
685        {
686            return Some(w.as_ref());
687        }
688        self.pinned.get(hash).map(|w| w.as_ref())
689    }
690
691    /// The definition a run executes against: the one it started with,
692    /// identified by hash — the current definition while it is unchanged, else
693    /// the copy a reload pinned. A restored run whose definition changed
694    /// underneath it matches neither, and `resume_policy: refuse` then stops
695    /// it rather than silently running it against a different graph.
696    pub(crate) fn definition_for_run(&self, run_id: &str) -> Option<std::sync::Arc<Workflow>> {
697        let run = self.runs.get(run_id)?;
698        if let Some(w) = self.workflows.get(&run.workflow)
699            && w.hash == run.workflow_hash
700        {
701            // An Arc clone: a refcount bump, not a graph copy — this is on
702            // the per-step hot path (measured ~10% of a chain's cycles as a
703            // deep clone).
704            return Some(w.clone());
705        }
706        self.pinned.get(&run.workflow_hash).cloned()
707    }
708
709    fn schedule_run(&mut self, run_id: &str) {
710        let Some(wf) = self.definition_for_run(run_id) else {
711            // The definition vanished or changed (hash mismatch): refuse to
712            // continue the run (`resume_policy: refuse`).
713            let (name, hash) = self
714                .runs
715                .get(run_id)
716                .map(|r| (r.workflow.clone(), r.workflow_hash.clone()))
717                .unwrap_or_default();
718            let reason = if self.workflows.contains_key(&name) {
719                format!(
720                    "workflow {name:?} definition changed (run pinned to hash {}); resume_policy refuse",
721                    &hash[..hash.len().min(12)]
722                )
723            } else {
724                format!("workflow {name:?} definition is gone")
725            };
726            self.log
727                .warn("run.refused", json!({"run": run_id, "reason": reason}));
728            if let Some(r) = self.runs.get_mut(run_id) {
729                r.finish(RunStatus::Refused, None, Some(reason));
730            }
731            self.on_run_terminal(run_id);
732            return;
733        };
734        if let Some(r) = self.runs.get(run_id)
735            && run::deadline_passed(r)
736        {
737            self.log.warn("run.deadline", json!({"run": run_id}));
738            self.cancel_children_of_run(run_id, "run deadline");
739            self.runs.get_mut(run_id).expect("present").finish(
740                RunStatus::Failed,
741                None,
742                Some("deadline exceeded".into()),
743            );
744            self.on_run_terminal(run_id);
745            return;
746        }
747        // A workflow that declares no budget inherits the instance's
748        // `limits.run.*`, so a definition silent about limits is still bounded.
749        // An unbounded run is the one shape where a single mistake can spend
750        // the whole day's tokens, and the instance-wide knob exists precisely
751        // to cap that.
752        let step_cap = wf.limits.steps.or(self.settings.limits.run.steps);
753        let token_cap = wf.limits.tokens.or(self.settings.limits.run.tokens);
754        if let Some(cap) = step_cap
755            && self.runs.get(run_id).is_some_and(|r| r.steps_run >= cap)
756        {
757            self.runs.get_mut(run_id).expect("present").finish(
758                RunStatus::Failed,
759                None,
760                Some(format!("exhausted steps: limit {cap}")),
761            );
762            self.on_run_terminal(run_id);
763            return;
764        }
765        if let Some(cap) = token_cap
766            && self.runs.get(run_id).is_some_and(|r| r.tokens >= cap)
767        {
768            self.runs.get_mut(run_id).expect("present").finish(
769                RunStatus::Failed,
770                None,
771                Some(format!("exhausted tokens: limits.tokens = {cap}")),
772            );
773            self.on_run_terminal(run_id);
774            return;
775        }
776        let data = self.run_data(run_id);
777        let next = {
778            let run = self.runs.get_mut(run_id).expect("present");
779            run::schedule(&wf, run, &data)
780        };
781        // Nested parents in flight advance every tick (rate pacing, timeouts,
782        // fresh iterations).
783        let nested: Vec<String> = self
784            .runs
785            .get(run_id)
786            .map(|r| {
787                r.steps
788                    .iter()
789                    .filter(|(_, st)| {
790                        st.status == StepStatus::Running
791                            && st.wait.as_ref().is_some_and(|w| {
792                                matches!(
793                                    w["kind"].as_str(),
794                                    Some("foreach")
795                                        | Some("batch")
796                                        | Some("iterate")
797                                        | Some("parallel")
798                                        | Some("race")
799                                        | Some("subgraph")
800                                )
801                            })
802                    })
803                    .map(|(id, _)| id.clone())
804                    .collect()
805            })
806            .unwrap_or_default();
807        for id in nested {
808            self.nested_advance(run_id, &id);
809        }
810        // Draining stops starting new steps — with one deliberate exception:
811        // a workflow that declares a `lifecycle.shutdown` start exists to run
812        // DURING the drain (deregister the webhook, flush the summary), and
813        // the drain gate waits for it. Everything else parks where it is,
814        // checkpointed, and resumes next life.
815        let shutdown_capable = self.draining
816            && wf
817                .start_steps()
818                .iter()
819                .any(|s| s.kind == "event" && s.field_str("on") == Some("lifecycle.shutdown"));
820        match next {
821            Ok(Next::Ready(steps)) => {
822                for s in steps {
823                    if self.draining && !shutdown_capable {
824                        return;
825                    }
826                    self.execute_step(run_id, &s);
827                }
828            }
829            Ok(Next::Waiting) | Ok(Next::Terminal) => {}
830            Ok(Next::Stalled) => {
831                // "No ready step" is a symptom, not a diagnosis. Almost always
832                // something upstream failed and its dependents could never
833                // become ready — so name the first failed ancestor rather than
834                // leaving whoever reads this to walk the graph themselves.
835                let culprit = self.first_failed_step(run_id);
836                let why = match &culprit {
837                    Some((sid, err)) => format!(
838                        "no ready step and no finish reached — step {sid:?} failed first: {err}"
839                    ),
840                    None => "no ready step and no finish reached".to_string(),
841                };
842                self.log.warn(
843                    "run.stalled",
844                    json!({"run": run_id, "blocked_by": culprit.as_ref().map(|(s, _)| s)}),
845                );
846                // A stall caused by a failure is a FAILED run, not a stalled
847                // one: the distinction matters to an exit code and to a caller.
848                let status = if culprit.is_some() {
849                    RunStatus::Failed
850                } else {
851                    RunStatus::Stalled
852                };
853                self.runs
854                    .get_mut(run_id)
855                    .expect("present")
856                    .finish(status, None, Some(why));
857                self.on_run_terminal(run_id);
858            }
859            Err(e) => {
860                self.runs.get_mut(run_id).expect("present").finish(
861                    RunStatus::Failed,
862                    None,
863                    Some(e),
864                );
865                self.on_run_terminal(run_id);
866            }
867        }
868    }
869
870    /// Refuse a definition-mutating tool when `security.workflows.immutable`.
871    ///
872    /// Applies to everyone — the model, a subagent, an operator over A2A —
873    /// because the point is that definitions are reviewed before they ship, and
874    /// a lock one caller can talk its way past is not a lock. Says how to change
875    /// them properly rather than only saying no.
876    fn workflows_locked(&self, tool: &str) -> Option<super::tools::ToolOutcome> {
877        if !self.settings.security.workflows.immutable {
878            return None;
879        }
880        self.log.warn("workflow.locked", json!({"tool": tool}));
881        Some(super::tools::ToolOutcome::Ready(
882            json!({"error": format!(
883                "{tool}: workflow definitions are immutable \
884                 (security.workflows.immutable) — edit the config, file or \
885                 directory they load from and reload"
886            )}),
887            true,
888        ))
889    }
890
891    /// The earliest step of a run that failed, with its error.
892    ///
893    /// This is what explains a stall: a run with no ready step is usually a run
894    /// whose dependency chain is blocked behind a failure that was routed away
895    /// from `on_error: fail`, and that failure is what a person needs to see.
896    fn first_failed_step(&self, run_id: &str) -> Option<(String, String)> {
897        let run = self.runs.get(run_id)?;
898        run.steps
899            .iter()
900            .filter(|(_, st)| {
901                matches!(
902                    st.status,
903                    StepStatus::Failed | StepStatus::Timeout | StepStatus::Cancelled
904                )
905            })
906            .min_by_key(|(_, st)| st.finished.unwrap_or(u64::MAX))
907            .map(|(id, st)| {
908                (
909                    id.clone(),
910                    st.error
911                        .clone()
912                        .unwrap_or_else(|| "no error recorded".into()),
913                )
914            })
915    }
916
917    /// The template data a run's specs render against: the `env` view, the
918    /// run's own fields, and `memory` as a read-through `{key: value}` map
919    /// holding exactly the `memory.<key>` references the definition names.
920    pub(crate) fn run_data(&mut self, run_id: &str) -> template::Data {
921        let env = env_view(
922            &self.instance,
923            run_id,
924            Some(&self.instruction.text),
925            self.settings.agent.prompt.as_deref(),
926        );
927        // Memory read-through: resolve every `memory.<key>` the definition
928        // names. The key scan walks the whole definition, so it is memoized
929        // per content hash — this runs per STEP, and re-walking an unchanged
930        // definition on every one of them lands squarely on the hot path.
931        let mut memory = Map::new();
932        let hash = self
933            .runs
934            .get(run_id)
935            .map(|r| r.workflow_hash.clone())
936            .unwrap_or_default();
937        if !hash.is_empty() {
938            if !self.memory_keys.contains_key(&hash) {
939                let mut keys: Vec<String> = Vec::new();
940                if let Some(wf) = self.definition_for_run(run_id) {
941                    for s in wf.steps.values() {
942                        for (_, v) in &s.spec {
943                            collect_memory_keys(v, &mut keys);
944                        }
945                        if let Some(w) = &s.when {
946                            collect_memory_keys(&Value::String(w.clone()), &mut keys);
947                        }
948                    }
949                }
950                self.memory_keys.insert(hash.clone(), keys);
951            }
952            for k in self.memory_keys.get(&hash).cloned().unwrap_or_default() {
953                if let Ok(v) = self.memory.get(&self.durable, &k)
954                    && v["found"] == json!(true)
955                {
956                    memory.insert(k, v["value"].clone());
957                }
958            }
959        }
960        let mut data = self
961            .runs
962            .get(run_id)
963            .map(|r| r.data(env, Value::Object(memory)))
964            .unwrap_or_default();
965        // Artifact-backed values (`{"$artifact": id}`) dereference transparently
966        // so a template sees the content, not the reference — a step never has
967        // to know whether an upstream output was spilled to an artifact.
968        for key in ["steps", "vars", "inputs"] {
969            if let Some(v) = data.get_mut(key) {
970                self.deref_artifacts(v);
971            }
972        }
973        data
974    }
975
976    /// Replace `{"$artifact": id, …}` objects with the artifact's content.
977    pub(crate) fn deref_artifacts(&self, v: &mut Value) {
978        match v {
979            Value::Object(o) => {
980                if let Some(id) = o.get("$artifact").and_then(Value::as_str) {
981                    if let Some(a) = self.artifacts.get(id) {
982                        *v = a.content.clone();
983                    }
984                    return;
985                }
986                for x in o.values_mut() {
987                    self.deref_artifacts(x);
988                }
989            }
990            Value::Array(a) => {
991                for x in a.iter_mut() {
992                    self.deref_artifacts(x);
993                }
994            }
995            _ => {}
996        }
997    }
998
999    // ---- execution -------------------------------------------------------------
1000
1001    /// Execute one ready step of a run.
1002    pub(crate) fn execute_step_pub(&mut self, run_id: &str, step_id: &str) {
1003        self.execute_step(run_id, step_id)
1004    }
1005
1006    fn execute_step(&mut self, run_id: &str, step_id: &str) {
1007        if self.runs.get(run_id).is_none_or(|r| r.status.is_terminal()) {
1008            return;
1009        }
1010        let Some(wf) = self
1011            .runs
1012            .get(run_id)
1013            .and_then(|_| self.definition_for_run(run_id))
1014        else {
1015            return;
1016        };
1017        let Some((step, scope)) = self
1018            .runs
1019            .get(run_id)
1020            .and_then(|r| self.resolve_step(&wf, r, step_id))
1021        else {
1022            return;
1023        };
1024        // Outbound throttling (`rate:` on a remote-effect kind): consulted
1025        // BEFORE `begin_step`, because a parked step has not attempted
1026        // anything — waiting for a token must consume neither an attempt nor
1027        // a retry. On an empty bucket the step suspends on a durable timer one
1028        // token-interval out and re-enters this gate when it fires.
1029        if matches!(
1030            step.kind.as_str(),
1031            "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1032        ) && let Some(rate) = step.spec.get("rate").and_then(Value::as_str)
1033            && let Ok((burst, secs)) = crate::supervisor::tree::parse_rate(rate)
1034        {
1035            let workflow = self
1036                .runs
1037                .get(run_id)
1038                .map(|r| r.workflow.clone())
1039                .unwrap_or_default();
1040            let key = super::breaker::key(&workflow, step_id);
1041            let (bucket, window_s, b) = self.step_rates.entry(key.clone()).or_insert_with(|| {
1042                (
1043                    crate::supervisor::tree::TokenBucket::new(burst, burst as f64 / secs),
1044                    secs,
1045                    burst,
1046                )
1047            });
1048            if !bucket.try_take() {
1049                // One token-interval, floored so a tight rate still parks
1050                // meaningfully rather than hot-looping the scheduler.
1051                let wait = ((*window_s * 1000.0) / (*b).max(1) as f64).max(20.0) as u64;
1052                match self.timers.arm(
1053                    &self.durable,
1054                    now_ms() + wait,
1055                    json!({"kind": "step_budget", "run": run_id, "step": step_id}),
1056                    Value::Null,
1057                ) {
1058                    Ok(id) => {
1059                        self.log.info(
1060                            "step.rate_wait",
1061                            json!({"run": run_id, "step": step_id, "rate": rate, "wait_ms": wait}),
1062                        );
1063                        self.runs
1064                            .get_mut(run_id)
1065                            .expect("present")
1066                            .suspend_step(step_id, json!({"kind": "rate_wait", "timer": id}));
1067                        self.checkpoint(false);
1068                        return;
1069                    }
1070                    Err(e) => {
1071                        // A store that cannot arm the wait must not turn a
1072                        // throttle into a hot loop; proceed unthrottled and say so.
1073                        self.log.warn(
1074                            "step.rate_wait_fail",
1075                            json!({"run": run_id, "step": step_id, "err": e.to_string()}),
1076                        );
1077                    }
1078                }
1079            }
1080        }
1081        let attempt = self
1082            .runs
1083            .get_mut(run_id)
1084            .expect("present")
1085            .begin_step(step_id);
1086        // A breakpoint set with `workflow.pause {before_step}` stops here — the
1087        // step has not begun, so the run can be inspected in the state it is in
1088        // rather than one effect later.
1089        if self
1090            .runs
1091            .get(run_id)
1092            .and_then(|r| r.break_before.as_deref())
1093            == Some(step_id)
1094        {
1095            if let Some(r) = self.runs.get_mut(run_id) {
1096                r.status = RunStatus::Paused;
1097                r.break_before = None;
1098                r.dirty = true;
1099                r.steps.entry(step_id.to_string()).or_default().status = StepStatus::Pending;
1100            }
1101            self.log
1102                .info("run.paused", json!({"run": run_id, "before_step": step_id}));
1103            self.checkpoint(true);
1104            return;
1105        }
1106        // Mark the step durably `running` BEFORE its effect leaves the process,
1107        // so a crash replays an effect that may already have happened rather
1108        // than losing one entirely. A pure data step has no effect to guard —
1109        // a crash replays it deterministically from the last checkpoint — so an
1110        // inline chain batches into the tick's single checkpoint instead of
1111        // paying a serialize+write per step.
1112        crate::state::kill_point("step.running");
1113        if !crate::engine::model::pure_data_kind(&step.kind) {
1114            self.checkpoint(false);
1115        }
1116        self.log.info(
1117            "step.start",
1118            json!({"run": run_id, "step": step_id, "kind": step.kind, "attempt": attempt}),
1119        );
1120        // One feed event per step transition: run-level counts alone ("3 done,
1121        // 1 running") tell a display client that a run is moving but never WHAT
1122        // is moving. Operator-scoped, because a step id and its kind describe
1123        // the workflow's internals. The feed is the A2A interface surface, so
1124        // without that feature there is nothing to push to.
1125        #[cfg(feature = "a2a")]
1126        self.feed_push(
1127            "step",
1128            crate::runtime::a2a_server::FeedVis::Operator,
1129            json!({"run": run_id, "step": step_id, "kind": step.kind,
1130                   "phase": "start", "attempt": attempt}),
1131        );
1132        let mut data = match &scope {
1133            Some(sc) => self.scoped_data(run_id, sc),
1134            None => self.run_data(run_id),
1135        };
1136        // Step-scoped env: the identity a RETRY of this step shares. Anything a
1137        // template derives from these is stable across attempts — which is what
1138        // makes `env.idempotency_key` an idempotency key and not a fresh id per
1139        // try. (`env.ts` already exists for when a per-attempt value is what
1140        // you want; the two must not be confused.)
1141        if let Some(env) = data.get_mut("env") {
1142            env["step"] = json!(step_id);
1143            env["attempt"] = json!(attempt);
1144            env["idempotency_key"] = json!(crate::engine::run::idempotency_key(run_id, step_id));
1145        }
1146        let spec = match render_spec(&step, &data) {
1147            Ok(s) => s,
1148            Err(e) => {
1149                self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0);
1150                return;
1151            }
1152        };
1153        let step_caller = ToolCaller {
1154            run: Some(run_id.to_string()),
1155            step: Some(step_id.to_string()),
1156            req: attempt as u64,
1157            principal: self.runs.get(run_id).and_then(|r| r.principal.clone()),
1158            ctx: self.runs.get(run_id).and_then(|r| r.conversation.clone()),
1159            msg_depth: self.runs.get(run_id).map(|r| r.msg_depth).unwrap_or(0),
1160            ..Default::default()
1161        };
1162        // `cache {key, ttl}`: a fresh memoized output skips the effect.
1163        let cache_key = match self.cache_lookup(&step, &spec, &data) {
1164            Some((_key, Some(hit))) => {
1165                self.log
1166                    .info("step.cache_hit", json!({"run": run_id, "step": step_id}));
1167                self.finish_step(run_id, step_id, StepStatus::Done, Some(hit), None, 0);
1168                return;
1169            }
1170            Some((key, None)) => Some(key),
1171            None => None,
1172        };
1173        if let Some(k) = cache_key
1174            && let Some(st) = self
1175                .runs
1176                .get_mut(run_id)
1177                .and_then(|r| r.steps.get_mut(step_id))
1178        {
1179            st.cache_key = Some(k);
1180        }
1181        // The circuit breaker (`breaker:` on a remote-effect kind): consulted
1182        // BEFORE the effect is dispatched, on the loop, so an open circuit
1183        // costs a map lookup instead of a connection + timeout. A fast-fail is
1184        // an ordinary step failure carrying `breaker::OPEN_ERR` — retry and
1185        // on_error compose with it; the recorder in `finish_step` skips it.
1186        if matches!(
1187            step.kind.as_str(),
1188            "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1189        ) && let Some(cfg) = self.effective_breaker(&step)
1190        {
1191            let workflow = self
1192                .runs
1193                .get(run_id)
1194                .map(|r| r.workflow.clone())
1195                .unwrap_or_default();
1196            let key = super::breaker::key(&workflow, step_id);
1197            let mut st = self
1198                .durable
1199                .manifest()
1200                .breakers
1201                .get(&key)
1202                .cloned()
1203                .unwrap_or_else(|| json!({}));
1204            match super::breaker::gate(&mut st, cfg, now_ms()) {
1205                super::breaker::Gate::Proceed => {}
1206                super::breaker::Gate::Probe => {
1207                    // This attempt claimed the half-open probe slot; the claim
1208                    // is durable so a concurrent run (or a restart) sees it.
1209                    self.durable.manifest_update(|m| {
1210                        m.breakers.insert(key.clone(), st);
1211                    });
1212                    self.log
1213                        .info("breaker.probe", json!({"breaker": key, "run": run_id}));
1214                }
1215                super::breaker::Gate::FastFail { retry_in_ms } => {
1216                    self.finish_step(
1217                        run_id,
1218                        step_id,
1219                        StepStatus::Failed,
1220                        None,
1221                        Some(format!(
1222                            "{} — failing fast; next probe in {}ms",
1223                            super::breaker::OPEN_ERR,
1224                            retry_in_ms
1225                        )),
1226                        0,
1227                    );
1228                    return;
1229                }
1230            }
1231        }
1232        match step.kind.as_str() {
1233            "checkpoint" => {
1234                // Documented as "force a durable checkpoint here rather than at
1235                // the next natural boundary", and implemented as an alias for
1236                // `noop` — so the one step whose entire purpose is to write did
1237                // not write. `true` forces the write rather than letting the
1238                // policy decide.
1239                self.checkpoint(true);
1240                self.finish_step(
1241                    run_id,
1242                    step_id,
1243                    StepStatus::Done,
1244                    Some(Value::Null),
1245                    None,
1246                    0,
1247                );
1248            }
1249            "noop" => self.finish_step(
1250                run_id,
1251                step_id,
1252                StepStatus::Done,
1253                Some(Value::Null),
1254                None,
1255                0,
1256            ),
1257            "assign" | "transform" => {
1258                let value = spec.get("value").cloned().unwrap_or(Value::Null);
1259                let key = spec
1260                    .get("writes")
1261                    .and_then(Value::as_str)
1262                    .unwrap_or(step_id)
1263                    .to_string();
1264                // A declared `state` key carries a schema; a write that breaks
1265                // it fails the step where the bad value is produced, rather
1266                // than three steps later where a template reads a shape nobody
1267                // expected. This is the whole reason to declare state.
1268                if let Some(schema) = self
1269                    .definition_for_run(run_id)
1270                    .and_then(|wf| wf.state.get(&key).and_then(|d| d.schema.clone()))
1271                    && let Err(errs) = crate::jsonschema::validate(&schema, &value)
1272                {
1273                    self.finish_step_pub(
1274                        run_id,
1275                        step_id,
1276                        StepStatus::Failed,
1277                        None,
1278                        Some(format!(
1279                            "assign: value does not match the schema declared for state \
1280                             {key:?}: {}",
1281                            errs.join("; ")
1282                        )),
1283                        0,
1284                    );
1285                    return;
1286                }
1287                let mode = spec
1288                    .get("mode")
1289                    .and_then(Value::as_str)
1290                    .unwrap_or("overwrite")
1291                    .to_string();
1292                self.runs
1293                    .get_mut(run_id)
1294                    .expect("present")
1295                    .write_var(&key, value.clone(), &mode);
1296                self.finish_step(run_id, step_id, StepStatus::Done, Some(value), None, 0);
1297            }
1298            "template" => {
1299                let out = spec
1300                    .get("text")
1301                    .cloned()
1302                    .or_else(|| spec.get("value").cloned())
1303                    .unwrap_or(Value::String(String::new()));
1304                self.finish_step(run_id, step_id, StepStatus::Done, Some(out), None, 0);
1305            }
1306            "validate" => {
1307                let value = spec.get("value").cloned().unwrap_or(Value::Null);
1308                let schema = spec.get("schema").cloned().unwrap_or(json!({}));
1309                match crate::jsonschema::validate(&schema, &value) {
1310                    Ok(()) => {
1311                        self.finish_step(run_id, step_id, StepStatus::Done, Some(value), None, 0)
1312                    }
1313                    Err(e) => self.finish_step(
1314                        run_id,
1315                        step_id,
1316                        StepStatus::Failed,
1317                        Some(value),
1318                        Some(format!(
1319                            "validation failed: {}",
1320                            crate::jsonschema::explain(&e)
1321                        )),
1322                        0,
1323                    ),
1324                }
1325            }
1326            "assert" => {
1327                let cond = step
1328                    .field_str("condition")
1329                    .unwrap_or("false")
1330                    .trim()
1331                    .trim_start_matches("CEL:")
1332                    .trim()
1333                    .to_string();
1334                let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
1335                match crate::cel::eval_bool(&cond, &vars) {
1336                    Ok(true) => self.finish_step(
1337                        run_id,
1338                        step_id,
1339                        StepStatus::Done,
1340                        Some(json!(true)),
1341                        None,
1342                        0,
1343                    ),
1344                    Ok(false) => self.finish_step(
1345                        run_id,
1346                        step_id,
1347                        StepStatus::Failed,
1348                        Some(json!(false)),
1349                        Some(
1350                            spec.get("message")
1351                                .and_then(Value::as_str)
1352                                .map(str::to_string)
1353                                .unwrap_or_else(|| format!("assertion failed: {cond}")),
1354                        ),
1355                        0,
1356                    ),
1357                    Err(e) => self.finish_step(
1358                        run_id,
1359                        step_id,
1360                        StepStatus::Failed,
1361                        None,
1362                        Some(format!("assert: {e}")),
1363                        0,
1364                    ),
1365                }
1366            }
1367            "fail" => {
1368                let msg = spec
1369                    .get("message")
1370                    .and_then(Value::as_str)
1371                    .unwrap_or("deliberate failure")
1372                    .to_string();
1373                self.finish_step(
1374                    run_id,
1375                    step_id,
1376                    StepStatus::Failed,
1377                    spec.get("code").cloned(),
1378                    Some(msg),
1379                    0,
1380                );
1381            }
1382            "emit" => {
1383                // With `stream:` this publishes an event to that stream;
1384                // without one it emits a note/audit record. One step name,
1385                // addressed by which fields are present.
1386                if let Some(stream) = spec.get("stream").and_then(Value::as_str) {
1387                    let stream = stream.to_string();
1388                    let subject = spec
1389                        .get("subject")
1390                        .and_then(Value::as_str)
1391                        .unwrap_or("")
1392                        .to_string();
1393                    let correlation = spec
1394                        .get("correlation")
1395                        .and_then(Value::as_str)
1396                        .map(str::to_string);
1397                    let data = spec.get("data").cloned().unwrap_or(Value::Null);
1398                    // The event id IS the step's derived idempotency key: a
1399                    // crash-replayed emit appends a second copy under the same
1400                    // id, and consumers drop it from their recent-id ring, so
1401                    // delivery is at-least-once without a duplicate surviving.
1402                    let id = crate::engine::run::idempotency_key(run_id, step_id);
1403                    let source = self
1404                        .runs
1405                        .get(run_id)
1406                        .map(|r| r.workflow.clone())
1407                        .unwrap_or_default();
1408                    match self.append_event(
1409                        &stream,
1410                        &subject,
1411                        correlation.as_deref(),
1412                        data,
1413                        &id,
1414                        &source,
1415                    ) {
1416                        Ok(seq) => {
1417                            self.log.info(
1418                                "stream.emit",
1419                                json!({"run": run_id, "step": step_id, "stream": stream,
1420                                       "subject": subject, "seq": seq}),
1421                            );
1422                            // `forward: {webhook: URL}` — push the event out as
1423                            // it appends (RFC 0035 §5). The DURABLE COPY is the
1424                            // source of truth and the push is only the
1425                            // notification, so a failed forward is logged and
1426                            // does not fail the step: the event is on the
1427                            // stream either way, and a consumer that missed the
1428                            // push still gets it from the offset.
1429                            let fwd = crate::runtime::streams::Forwarded {
1430                                stream: &stream,
1431                                subject: &subject,
1432                                id: &id,
1433                                seq,
1434                                run_id,
1435                                step_id,
1436                            };
1437                            if let Some(url) = spec
1438                                .get("forward")
1439                                .and_then(|f| f.get("webhook"))
1440                                .and_then(Value::as_str)
1441                            {
1442                                // Same posture as the `http` node: reaching a
1443                                // private/loopback address is a per-node
1444                                // opt-in, never a default.
1445                                let allow_private = spec
1446                                    .get("forward")
1447                                    .and_then(|f| f.get("allow_private"))
1448                                    .and_then(Value::as_bool)
1449                                    .unwrap_or(false);
1450                                self.forward_event(url, &fwd, allow_private);
1451                            }
1452                            #[cfg(feature = "a2a")]
1453                            if let Some(peer) = spec
1454                                .get("forward")
1455                                .and_then(|f| f.get("peer"))
1456                                .and_then(Value::as_str)
1457                            {
1458                                let data = spec.get("data").cloned().unwrap_or(Value::Null);
1459                                self.forward_event_peer(peer, &fwd, &data);
1460                            }
1461                            self.finish_step(
1462                                run_id,
1463                                step_id,
1464                                StepStatus::Done,
1465                                Some(json!({"id": id, "seq": seq, "stream": stream,
1466                                           "subject": subject})),
1467                                None,
1468                                0,
1469                            );
1470                        }
1471                        Err(e) => {
1472                            self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1473                        }
1474                    }
1475                    return;
1476                }
1477                if let Some(n) = spec.get("note").and_then(Value::as_str) {
1478                    let text = format!("run {run_id}: {n}");
1479                    self.note_root(text);
1480                }
1481                if let Some(a) = spec.get("audit") {
1482                    self.log.info(
1483                        "audit.emit",
1484                        json!({"run": run_id, "step": step_id, "audit": a}),
1485                    );
1486                }
1487                self.finish_step(
1488                    run_id,
1489                    step_id,
1490                    StepStatus::Done,
1491                    spec.get("value").cloned().or(Some(Value::Null)),
1492                    None,
1493                    0,
1494                );
1495            }
1496            "finish" => {
1497                let status = match spec
1498                    .get("status")
1499                    .and_then(Value::as_str)
1500                    .unwrap_or("completed")
1501                {
1502                    "completed" => RunStatus::Completed,
1503                    "refused" => RunStatus::Refused,
1504                    "cancelled" => RunStatus::Cancelled,
1505                    _ => RunStatus::Failed,
1506                };
1507                let output = spec.get("output").cloned();
1508                // `outputs.schema` was checked for well-formedness at parse time
1509                // and then never applied — a workflow could declare the shape of
1510                // its result and return anything at all. Enforce it here, where
1511                // the result actually exists. A completed run whose output does
1512                // not match what it promised is a FAILED run: a caller reading
1513                // the declared shape is the whole reason to declare one.
1514                if matches!(status, RunStatus::Completed)
1515                    && let Some(schema) = self
1516                        .definition_for_run(run_id)
1517                        .and_then(|wf| wf.outputs_schema.clone())
1518                {
1519                    let value = output.clone().unwrap_or(Value::Null);
1520                    if let Err(errs) = crate::jsonschema::validate(&schema, &value) {
1521                        self.finish_step_pub(
1522                            run_id,
1523                            step_id,
1524                            StepStatus::Failed,
1525                            None,
1526                            Some(format!(
1527                                "finish: output does not match the workflow's declared \
1528                                 outputs.schema: {}",
1529                                errs.join("; ")
1530                            )),
1531                            0,
1532                        );
1533                        return;
1534                    }
1535                }
1536                let reason = spec
1537                    .get("reason")
1538                    .and_then(Value::as_str)
1539                    .map(str::to_string);
1540                self.runs.get_mut(run_id).expect("present").end_step(
1541                    step_id,
1542                    StepStatus::Done,
1543                    output.clone(),
1544                    None,
1545                );
1546                self.runs
1547                    .get_mut(run_id)
1548                    .expect("present")
1549                    .finish(status, output, reason);
1550                self.on_run_terminal(run_id);
1551            }
1552            "sleep" => {
1553                let ms = spec
1554                    .get("duration")
1555                    .map(crate::engine::model::duration_ms)
1556                    .unwrap_or(Ok(0))
1557                    .unwrap_or(0);
1558                match self.timers.arm(
1559                    &self.durable,
1560                    now_ms() + ms,
1561                    json!({"kind": "step", "run": run_id, "step": step_id}),
1562                    json!({"slept_ms": ms}),
1563                ) {
1564                    Ok(id) => {
1565                        self.runs.get_mut(run_id).expect("present").suspend_step(
1566                            step_id,
1567                            json!({"kind": "sleep", "timer": id, "deadline_ms": now_ms() + ms}),
1568                        );
1569                        self.checkpoint(false);
1570                    }
1571                    Err(e) => self.finish_step(
1572                        run_id,
1573                        step_id,
1574                        StepStatus::Failed,
1575                        None,
1576                        Some(format!("sleep: {e}")),
1577                        0,
1578                    ),
1579                }
1580            }
1581            "tool" => {
1582                let name = spec
1583                    .get("name")
1584                    .and_then(Value::as_str)
1585                    .unwrap_or("")
1586                    .to_string();
1587                let args = spec.get("args").cloned().unwrap_or(json!({}));
1588                self.step_tool_call(run_id, step_id, &step_caller, &name, args);
1589            }
1590            "http" => self.step_http(run_id, step_id, &spec),
1591            k if k.starts_with("memory.")
1592                || k.starts_with("artifact.")
1593                || k.starts_with("knowledge.")
1594                || k.starts_with("search.") =>
1595            {
1596                let mut args = spec.clone();
1597                // `ttl` etc. pass through as-is; the contract validates.
1598                args.retain(|_, v| !v.is_null());
1599                self.step_tool_call(run_id, step_id, &step_caller, k, Value::Object(args));
1600            }
1601            "mcp.tool" => {
1602                let server = spec
1603                    .get("server")
1604                    .and_then(Value::as_str)
1605                    .unwrap_or("")
1606                    .to_string();
1607                let tool = spec
1608                    .get("tool")
1609                    .and_then(Value::as_str)
1610                    .unwrap_or("")
1611                    .to_string();
1612                let args = spec.get("args").cloned().unwrap_or(json!({}));
1613                // Pace calls toward a rated catalog service. A dry bucket fails
1614                // the step — a refusal the workflow's `retry:` can absorb —
1615                // rather than blocking the single-writer loop.
1616                if let Err(e) = self.service_rate_take(&server) {
1617                    self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0);
1618                    return;
1619                }
1620                let Some(client) = self.mcp.get(&server).cloned() else {
1621                    self.finish_step(
1622                        run_id,
1623                        step_id,
1624                        StepStatus::Failed,
1625                        None,
1626                        Some(format!("mcp server {server:?} is not connected")),
1627                        0,
1628                    );
1629                    return;
1630                };
1631                // The key must NOT vary by attempt: a retry that presents a
1632                // fresh key is exactly the duplicate the key exists to prevent,
1633                // so nothing per-attempt may enter it.
1634                // The attempt rides separately for servers that want to
1635                // OBSERVE retries without keying on them, and `idempotency:
1636                // {value: …}` substitutes an application-level key (an order
1637                // id) when one exists — which beats any run-derived key, since
1638                // it also collides two different RUNS attempting the same
1639                // real-world operation.
1640                let key = spec
1641                    .get("idempotency")
1642                    .and_then(|i| i.get("value"))
1643                    .and_then(Value::as_str)
1644                    .map(str::to_string)
1645                    .unwrap_or_else(|| crate::engine::run::idempotency_key(run_id, step_id));
1646                let meta = json!({"agent/idempotency_key": key, "agent/attempt": attempt, "agent/instance": self.instance, "agent/run": run_id});
1647                let timeout = step
1648                    .timeout_ms
1649                    .map(std::time::Duration::from_millis)
1650                    .unwrap_or(
1651                        self.settings
1652                            .limits
1653                            .step_timeout
1654                            .map(|d| d.0)
1655                            .unwrap_or(std::time::Duration::from_secs(600)),
1656                    );
1657                let tx = self.events_tx.clone();
1658                let (r, s) = (run_id.to_string(), step_id.to_string());
1659                self.executing
1660                    .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1661                std::thread::Builder::new()
1662                    .name(format!("step:{server}.{tool}"))
1663                    .spawn(move || {
1664                        let (output, is_error, error) = match client.call_tool_with_meta_within(
1665                            &tool,
1666                            Some(args),
1667                            meta,
1668                            timeout,
1669                        ) {
1670                            Ok(res) => {
1671                                let v = super::worker::tool_result_value(&res);
1672                                if res.is_error() {
1673                                    (v.clone(), true, Some(res.text()))
1674                                } else {
1675                                    (v, false, None)
1676                                }
1677                            }
1678                            Err(e) => (Value::Null, true, Some(format!("transport error: {e}"))),
1679                        };
1680                        let _ = tx.send(super::events::Event::StepDone {
1681                            run: r,
1682                            step: s,
1683                            output,
1684                            is_error,
1685                            error,
1686                            tokens: 0,
1687                        });
1688                    })
1689                    .ok();
1690            }
1691            "agent" | "think" => self.step_turn(run_id, step_id, &step, &spec, &data),
1692            "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph" => {
1693                self.nested_start(run_id, step_id, &step, &spec)
1694            }
1695            "wait" | "join" | "workflow" | "message" | "workflow.signal" | "workflow.wait"
1696            | "workflow.cancel" | "subagent" | "human" | "mcp.resource" | "a2a.delegate"
1697            | "a2a.send" | "a2a.wait" | "classify" | "extract" | "summarize" | "judge"
1698            | "route" => {
1699                self.execute_orchestration_step(run_id, step_id, &step, &spec, &data, &step_caller)
1700            }
1701            "switch" => {
1702                let on = spec.get("on").cloned().unwrap_or(Value::Null);
1703                let key = match &on {
1704                    Value::String(x) => x.clone(),
1705                    other => other.to_string(),
1706                };
1707                let cases = step
1708                    .field("cases")
1709                    .and_then(Value::as_object)
1710                    .cloned()
1711                    .unwrap_or_default();
1712                let target = cases
1713                    .get(&key)
1714                    .and_then(Value::as_str)
1715                    .map(str::to_string)
1716                    .or_else(|| step.field_str("default").map(str::to_string));
1717                match target {
1718                    Some(t) => {
1719                        // The chosen case runs even without its deps being terminal
1720                        // (an explicit routing edge); the other cases are skipped.
1721                        let scope_prefix = step_id
1722                            .rsplit_once('.')
1723                            .map(|(p, _)| format!("{p}."))
1724                            .unwrap_or_default();
1725                        let mut skipped = Vec::new();
1726                        // Every other target (cases + default) still pending is skipped;
1727                        // the chosen one is forced (runs even without its deps).
1728                        let mut others: Vec<String> = cases
1729                            .values()
1730                            .filter_map(Value::as_str)
1731                            .map(str::to_string)
1732                            .collect();
1733                        if let Some(d) = step.field_str("default") {
1734                            others.push(d.to_string());
1735                        }
1736                        if let Some(run) = self.runs.get_mut(run_id) {
1737                            for tid in others {
1738                                if tid == t {
1739                                    continue;
1740                                }
1741                                let sid = format!("{scope_prefix}{tid}");
1742                                if let Some(st) = run.steps.get_mut(&sid)
1743                                    && st.status == StepStatus::Pending
1744                                {
1745                                    // Pruned, not skipped: the case was not
1746                                    // chosen, so its whole tail is dead.
1747                                    st.status = StepStatus::Pruned;
1748                                    skipped.push(sid);
1749                                }
1750                            }
1751                            let sid = format!("{scope_prefix}{t}");
1752                            if let Some(st) = run.steps.get_mut(&sid) {
1753                                st.status = StepStatus::Pending;
1754                                st.forced = true;
1755                            }
1756                        }
1757                        self.finish_step(
1758                            run_id,
1759                            step_id,
1760                            StepStatus::Done,
1761                            Some(json!({"case": key, "goto": t, "skipped": skipped})),
1762                            None,
1763                            0,
1764                        );
1765                    }
1766                    // No case, no default: `on_no_match: skip` prunes every
1767                    // branch and completes — for a switch whose "else" is
1768                    // honestly "do nothing". The default stays fail-closed.
1769                    None if step.field_str("on_no_match") == Some("skip") => {
1770                        let scope_prefix = step_id
1771                            .rsplit_once('.')
1772                            .map(|(p, _)| format!("{p}."))
1773                            .unwrap_or_default();
1774                        let mut skipped = Vec::new();
1775                        let others: Vec<String> = cases
1776                            .values()
1777                            .filter_map(Value::as_str)
1778                            .map(str::to_string)
1779                            .collect();
1780                        if let Some(run) = self.runs.get_mut(run_id) {
1781                            for tid in others {
1782                                let sid = format!("{scope_prefix}{tid}");
1783                                if let Some(st) = run.steps.get_mut(&sid)
1784                                    && st.status == StepStatus::Pending
1785                                {
1786                                    st.status = StepStatus::Pruned;
1787                                    skipped.push(sid);
1788                                }
1789                            }
1790                        }
1791                        self.finish_step(
1792                            run_id,
1793                            step_id,
1794                            StepStatus::Done,
1795                            Some(json!({"case": key, "matched": false, "skipped": skipped})),
1796                            None,
1797                            0,
1798                        );
1799                    }
1800                    None => self.finish_step(
1801                        run_id,
1802                        step_id,
1803                        StepStatus::Failed,
1804                        Some(json!({"case": key})),
1805                        Some(format!("switch: no case for {key:?} and no default")),
1806                        0,
1807                    ),
1808                }
1809            }
1810            "map" | "filter" | "reduce" | "sort" | "dedupe" | "chunk" | "parse" => {
1811                let out = match step.kind.as_str() {
1812                    "map" => crate::engine::data::map(
1813                        spec.get("over").unwrap_or(&Value::Null),
1814                        step.field_str("expr").unwrap_or(""),
1815                        step.field_str("as").unwrap_or("item"),
1816                        &data,
1817                    ),
1818                    "filter" => crate::engine::data::filter(
1819                        spec.get("over").unwrap_or(&Value::Null),
1820                        step.field_str("expr").unwrap_or(""),
1821                        step.field_str("as").unwrap_or("item"),
1822                        &data,
1823                    ),
1824                    "reduce" => crate::engine::data::reduce(
1825                        spec.get("over").unwrap_or(&Value::Null),
1826                        step.field_str("expr").unwrap_or(""),
1827                        spec.get("initial").cloned().unwrap_or(Value::Null),
1828                        step.field_str("as").unwrap_or("item"),
1829                        step.field_str("acc").unwrap_or("acc"),
1830                        &data,
1831                    ),
1832                    "sort" => crate::engine::data::sort(
1833                        spec.get("over").unwrap_or(&Value::Null),
1834                        spec.get("by").and_then(Value::as_str),
1835                        spec.get("order").and_then(Value::as_str),
1836                    ),
1837                    "dedupe" => crate::engine::data::dedupe(
1838                        spec.get("over").unwrap_or(&Value::Null),
1839                        spec.get("by").and_then(Value::as_str),
1840                    ),
1841                    "chunk" => crate::engine::data::chunk(
1842                        spec.get("value").unwrap_or(&Value::Null),
1843                        spec.get("by").and_then(Value::as_str),
1844                        spec.get("size").and_then(Value::as_u64).unwrap_or(0) as usize,
1845                        spec.get("overlap").and_then(Value::as_u64).unwrap_or(0) as usize,
1846                    ),
1847                    _ => crate::engine::data::parse(
1848                        spec.get("text").and_then(Value::as_str).unwrap_or(""),
1849                        spec.get("format").and_then(Value::as_str),
1850                    ),
1851                };
1852                match out {
1853                    Ok(v) => self.finish_step(run_id, step_id, StepStatus::Done, Some(v), None, 0),
1854                    Err(e) => {
1855                        self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1856                    }
1857                }
1858            }
1859            other => self.finish_step(
1860                run_id,
1861                step_id,
1862                StepStatus::Failed,
1863                None,
1864                Some(format!(
1865                    "step kind {other:?} is not executable in this build"
1866                )),
1867                0,
1868            ),
1869        }
1870    }
1871
1872    /// A step's internal tool call (in-loop or deferred/executor).
1873    fn step_tool_call(
1874        &mut self,
1875        run_id: &str,
1876        step_id: &str,
1877        caller: &ToolCaller,
1878        name: &str,
1879        args: Value,
1880    ) {
1881        match self.execute_tool(caller, name, args) {
1882            ToolOutcome::Ready(v, is_error) => {
1883                let err = is_error.then(|| match &v {
1884                    Value::String(s) => s.clone(),
1885                    o => o.to_string(),
1886                });
1887                self.finish_step(
1888                    run_id,
1889                    step_id,
1890                    if is_error {
1891                        StepStatus::Failed
1892                    } else {
1893                        StepStatus::Done
1894                    },
1895                    Some(v),
1896                    err,
1897                    0,
1898                );
1899            }
1900            ToolOutcome::Deferred(kind) => {
1901                let wait = match &kind {
1902                    PendingKind::Timer { id } => json!({"kind": "timer", "timer": id}),
1903                    PendingKind::Subagent { handle } => {
1904                        json!({"kind": "subagent", "handle": handle})
1905                    }
1906                    PendingKind::Think { .. } => json!({"kind": "think"}),
1907                    PendingKind::Run { run, .. } => json!({"kind": "run", "run": run}),
1908                    PendingKind::Await {
1909                        condition,
1910                        deadline_ms,
1911                    } => {
1912                        json!({"kind": "await", "condition": condition, "deadline_ms": deadline_ms})
1913                    }
1914                    PendingKind::Human {
1915                        task, deadline_ms, ..
1916                    } => {
1917                        json!({"kind": "human", "task": task, "deadline_ms": deadline_ms})
1918                    }
1919                };
1920                self.runs
1921                    .get_mut(run_id)
1922                    .expect("present")
1923                    .suspend_step(step_id, wait);
1924                if !matches!(kind, PendingKind::Timer { .. }) {
1925                    self.push_pending(super::reactor::PendingTool {
1926                        target: Target::Step(run_id.to_string(), step_id.to_string()),
1927                        name: name.to_string(),
1928                        kind,
1929                        started_ms: now_ms(),
1930                    });
1931                }
1932                self.checkpoint(false);
1933            }
1934            ToolOutcome::Executing => {
1935                self.executing
1936                    .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1937            }
1938        }
1939    }
1940
1941    pub(crate) fn step_turn_pub(
1942        &mut self,
1943        run_id: &str,
1944        step_id: &str,
1945        step: &Step,
1946        spec: &Map<String, Value>,
1947        data: &template::Data,
1948    ) {
1949        self.step_turn(run_id, step_id, step, spec, data)
1950    }
1951
1952    /// An `agent`/`think` step → a turn worker (budget-admitted).
1953    fn step_turn(
1954        &mut self,
1955        run_id: &str,
1956        step_id: &str,
1957        step: &Step,
1958        spec: &Map<String, Value>,
1959        data: &template::Data,
1960    ) {
1961        let is_think = step.kind == "think";
1962        let prompt = if is_think {
1963            spec.get("prompt").and_then(Value::as_str).unwrap_or("")
1964        } else {
1965            spec.get("instruction")
1966                .and_then(Value::as_str)
1967                .unwrap_or("")
1968        }
1969        .to_string();
1970        let output_schema = spec.get("output_schema").cloned();
1971        let mut messages = Vec::new();
1972        // `reads`: fold named run data into the prompt.
1973        if let Some(reads) = spec.get("reads").and_then(Value::as_array) {
1974            for path in reads.iter().filter_map(Value::as_str) {
1975                if let Some(v) = template::lookup(path, data) {
1976                    messages.push(Msg::system(format!("{path} = {v}")));
1977                }
1978            }
1979        }
1980        // `context`: seed messages — a bare array, or the object form
1981        // `{cards: [...], seed: [...]}` where `cards` controls which
1982        // environment sections THIS step's system prompt carries (node-level
1983        // context control; the config's `context.cards` is the default).
1984        let step_template: Option<String> = spec
1985            .get("context")
1986            .and_then(Value::as_object)
1987            .and_then(|o| o.get("template"))
1988            .and_then(Value::as_str)
1989            .map(str::to_string);
1990        let seed_list = spec.get("context").and_then(Value::as_array).or_else(|| {
1991            spec.get("context")
1992                .and_then(Value::as_object)
1993                .and_then(|o| o.get("seed"))
1994                .and_then(Value::as_array)
1995        });
1996        if let Some(seed) = seed_list {
1997            for m in seed {
1998                match (m["role"].as_str(), m["content"].as_str()) {
1999                    (Some("system"), Some(c)) => messages.push(Msg::system(c)),
2000                    (Some("assistant"), Some(c)) => {
2001                        messages.push(Msg::assistant(Some(c.to_string()), vec![]))
2002                    }
2003                    (_, Some(c)) => messages.push(Msg::user(c, None)),
2004                    _ => {}
2005                }
2006            }
2007        }
2008        let mut user = prompt.clone();
2009        if let Some(c) = spec.get("output_contract").and_then(Value::as_str) {
2010            user.push_str(&format!("\n\nOutput contract:\n{c}"));
2011        }
2012        if let Some(s) = &output_schema {
2013            user.push_str(&format!(
2014                "\n\nReply with ONLY one JSON object matching this JSON Schema:\n{s}"
2015            ));
2016        }
2017        messages.push(Msg::user(user, None));
2018        // Skills for the step.
2019        let skill_bodies: Vec<String> = step
2020            .skills
2021            .iter()
2022            .chain(
2023                spec.get("skills")
2024                    .and_then(Value::as_array)
2025                    .map(|a| {
2026                        a.iter()
2027                            .filter_map(Value::as_str)
2028                            .map(str::to_string)
2029                            .collect::<Vec<_>>()
2030                    })
2031                    .unwrap_or_default()
2032                    .iter(),
2033            )
2034            .filter_map(|name| {
2035                let mcp = self.mcp.clone();
2036                let resolver = move |server: &str| -> Option<
2037                    std::sync::Arc<dyn crate::context::skills::SkillServer>,
2038                > {
2039                    mcp.get(server).map(|c| {
2040                        c.clone() as std::sync::Arc<dyn crate::context::skills::SkillServer>
2041                    })
2042                };
2043                self.skills
2044                    .load(name, None, &resolver)
2045                    .ok()
2046                    .map(|b| format!("### Skill: {}\n{}", b.name, b.body))
2047            })
2048            .collect();
2049        let extra = if skill_bodies.is_empty() {
2050            None
2051        } else {
2052            Some(format!(
2053                "Loaded skills — follow these instructions when relevant:\n{}",
2054                skill_bodies.join("\n\n")
2055            ))
2056        };
2057        let system = match spec.get("system").and_then(Value::as_str) {
2058            Some(s) => s.to_string(),
2059            None if is_think => format!(
2060                "You are the reasoning module of {}. Reply with {}. No tools are available.",
2061                self.instance,
2062                if output_schema.is_some() {
2063                    "ONLY one JSON object matching the schema"
2064                } else {
2065                    "your conclusion"
2066                }
2067            ),
2068            None => self.system_prompt_named(None, extra.as_deref(), step_template.as_deref()),
2069        };
2070        let (tools, internal, routes) = if is_think {
2071            (Vec::new(), Vec::new(), BTreeMap::new())
2072        } else {
2073            let allow: Option<Vec<String>> = spec.get("tools").and_then(Value::as_array).map(|a| {
2074                a.iter()
2075                    .filter_map(Value::as_str)
2076                    .map(str::to_string)
2077                    .collect()
2078            });
2079            self.tool_plan(&Caller::Workflow, allow.as_deref())
2080        };
2081        let servers: Vec<String> = match spec.get("servers").and_then(Value::as_array) {
2082            Some(a) => a
2083                .iter()
2084                .filter_map(Value::as_str)
2085                .map(str::to_string)
2086                .collect(),
2087            None => routes
2088                .values()
2089                .map(|(s, _)| s.clone())
2090                .collect::<std::collections::BTreeSet<_>>()
2091                .into_iter()
2092                .collect(),
2093        };
2094        // Budget admission: the estimate is charged against every scope this
2095        // run belongs to as well as the instance, so the tightest one binds.
2096        let est: u64 = messages.iter().map(Msg::est_tokens).sum::<u64>()
2097            + crate::context::tokens::estimate(&system)
2098            + 4096;
2099        let scopes = self.run_scopes(run_id);
2100        let reservation = match self.governor.admit(est, &scopes, now_ms()) {
2101            Admission::Ok { reservation, model } => {
2102                if let Some(m) = model {
2103                    self.log.info(
2104                        "budget.degraded",
2105                        json!({"run": run_id, "step": step_id, "model": m}),
2106                    );
2107                }
2108                Some(reservation)
2109            }
2110            Admission::Wait { until_ms, reason } => {
2111                self.log.info(
2112                    "budget.wait",
2113                    json!({"run": run_id, "step": step_id, "until_ms": until_ms, "reason": reason}),
2114                );
2115                crate::state::kill_point("budget.waiting");
2116                match self.timers.arm(
2117                    &self.durable,
2118                    until_ms,
2119                    json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2120                    Value::Null,
2121                ) {
2122                    Ok(id) => {
2123                        self.runs.get_mut(run_id).expect("present").suspend_step(step_id, json!({"kind": "waiting_budget", "timer": id, "until_ms": until_ms, "reason": reason}));
2124                        self.checkpoint(false);
2125                    }
2126                    Err(e) => self.finish_step(
2127                        run_id,
2128                        step_id,
2129                        StepStatus::Failed,
2130                        None,
2131                        Some(format!("budget wait: {e}")),
2132                        0,
2133                    ),
2134                }
2135                return;
2136            }
2137            Admission::Refuse { reason } | Admission::Fail { reason } => {
2138                self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(reason), 0);
2139                return;
2140            }
2141        };
2142        // `model:` on an `agent`/`think` node: cost tiering inside one
2143        // workflow, without forking a subagent process just to change a model
2144        // string. Read here, because `spec` is shadowed by the `TurnSpec`
2145        // below.
2146        let node_model = spec
2147            .get("model")
2148            .and_then(Value::as_str)
2149            .map(str::to_string);
2150        let limits = spec.get("limits").cloned().unwrap_or(json!({}));
2151        let max_steps = limits
2152            .get("steps")
2153            .and_then(Value::as_u64)
2154            .map(|s| s as u32)
2155            .unwrap_or(self.settings.limits.run.steps());
2156        let max_tokens = step
2157            .budget
2158            .or_else(|| limits.get("tokens").and_then(Value::as_u64))
2159            .unwrap_or(self.settings.limits.run.tokens());
2160        let deadline_ms = step.timeout_ms.unwrap_or(
2161            self.settings
2162                .limits
2163                .step_timeout
2164                .map(|d| d.0.as_millis() as u64)
2165                .unwrap_or(600_000),
2166        );
2167        let spec = TurnSpec {
2168            kind: if is_think {
2169                TurnKind::Think
2170            } else {
2171                TurnKind::Agent
2172            },
2173            system,
2174            messages,
2175            tools,
2176            internal,
2177            mcp_routes: routes,
2178            output_schema,
2179            max_rounds: if is_think { 3 } else { 0 },
2180            budget_admission: self.governor.is_active(),
2181            idempotency_prefix: format!("{}/{run_id}/{step_id}", self.instance),
2182            tool_meta: Some(
2183                json!({"agent/run": run_id, "agent/step": step_id, "agent/instance": self.instance}),
2184            ),
2185            temperature: None,
2186            max_tokens_per_call: 0,
2187            turn_id: format!(
2188                "{run_id}/{step_id}#{}",
2189                self.runs
2190                    .get(run_id)
2191                    .and_then(|r| r.step(step_id))
2192                    .map(|s| s.attempt)
2193                    .unwrap_or(1)
2194            ),
2195        };
2196        let launch = super::turns::TurnLaunch {
2197            spec,
2198            kind: ChildKind::StepTurn {
2199                run: run_id.to_string(),
2200                step: step_id.to_string(),
2201                reservation,
2202            },
2203            servers,
2204            max_steps,
2205            max_tokens,
2206            deadline_ms,
2207            agent_path: format!("run/{run_id}/{step_id}"),
2208            model: node_model,
2209        };
2210        match self.spawn_turn(launch) {
2211            Ok(node) => {
2212                if let Some(st) = self
2213                    .runs
2214                    .get_mut(run_id)
2215                    .and_then(|r| r.steps.get_mut(step_id))
2216                {
2217                    st.worker = Some(node.0.to_string());
2218                }
2219                self.log.info(
2220                    "step.turn.spawn",
2221                    json!({"run": run_id, "step": step_id, "node": node.0}),
2222                );
2223            }
2224            Err(e) => {
2225                if let Some(r) = reservation {
2226                    self.governor.release(r);
2227                }
2228                self.finish_step(
2229                    run_id,
2230                    step_id,
2231                    StepStatus::Failed,
2232                    None,
2233                    Some(format!("spawn: {e}")),
2234                    0,
2235                );
2236            }
2237        }
2238    }
2239
2240    /// The governor scopes a run's turns are charged to (workflow budget → run scope).
2241    fn run_scopes(&mut self, run_id: &str) -> Vec<String> {
2242        // A run's model spend is charged to the principal it is being done for
2243        // as well as to the run itself, so a per-person ceiling covers the
2244        // work someone STARTED, not only the turns they typed.
2245        let mut scopes = self.principal_scopes(
2246            self.runs
2247                .get(run_id)
2248                .and_then(|r| r.principal.clone())
2249                .as_deref(),
2250        );
2251        let Some(wf) = self.definition_for_run(run_id) else {
2252            return scopes;
2253        };
2254        if let Some(b) = wf
2255            .limits
2256            .budget
2257            .as_ref()
2258            .and_then(|b| serde_json::from_value::<crate::config::v2::Budget>(b.clone()).ok())
2259        {
2260            let key = format!("run:{run_id}");
2261            self.governor.ensure_scope(&key, &b);
2262            scopes.push(key);
2263        }
2264        scopes
2265    }
2266
2267    // ---- outcomes --------------------------------------------------------------
2268
2269    /// An executor / deferred tool finished a step.
2270    pub(crate) fn on_step_done(
2271        &mut self,
2272        run_id: &str,
2273        step_id: &str,
2274        output: Value,
2275        is_error: bool,
2276        error: Option<String>,
2277        tokens: u64,
2278    ) {
2279        self.executing.remove(&format!("{run_id}/{step_id}"));
2280        self.finish_step(
2281            run_id,
2282            step_id,
2283            if is_error {
2284                StepStatus::Failed
2285            } else {
2286                StepStatus::Done
2287            },
2288            Some(output),
2289            error,
2290            tokens,
2291        );
2292    }
2293
2294    /// A step's turn worker finished.
2295    pub(crate) fn on_step_turn_done(&mut self, run_id: &str, step_id: &str, turn: TurnResult) {
2296        let tokens = turn.usage.total();
2297        if turn.status == "completed" {
2298            let output = turn
2299                .value
2300                .clone()
2301                .or_else(|| turn.finish.as_ref().and_then(|f| f.get("output").cloned()))
2302                .or_else(|| turn.text.clone().map(Value::String))
2303                .unwrap_or(Value::Null);
2304            // A `finish {status: failed|refused}` from an agent step fails the step.
2305            let failed = turn
2306                .finish
2307                .as_ref()
2308                .and_then(|f| f.get("status"))
2309                .and_then(Value::as_str)
2310                .is_some_and(|s| s != "completed");
2311            if failed {
2312                let reason = turn
2313                    .finish
2314                    .as_ref()
2315                    .and_then(|f| f.get("reason"))
2316                    .and_then(Value::as_str)
2317                    .unwrap_or("agent finished with a non-completed status")
2318                    .to_string();
2319                self.finish_step(
2320                    run_id,
2321                    step_id,
2322                    StepStatus::Failed,
2323                    Some(output),
2324                    Some(reason),
2325                    tokens,
2326                );
2327            } else {
2328                self.finish_step(
2329                    run_id,
2330                    step_id,
2331                    StepStatus::Done,
2332                    Some(output),
2333                    None,
2334                    tokens,
2335                );
2336            }
2337        } else {
2338            let status = if turn.status == "deadline" {
2339                StepStatus::Timeout
2340            } else {
2341                StepStatus::Failed
2342            };
2343            self.finish_step(
2344                run_id,
2345                step_id,
2346                status,
2347                turn.value
2348                    .clone()
2349                    .or_else(|| turn.text.clone().map(Value::String)),
2350                Some(format!(
2351                    "turn {}{}",
2352                    turn.status,
2353                    turn.error
2354                        .as_deref()
2355                        .map(|e| format!(": {e}"))
2356                        .unwrap_or_default()
2357                )),
2358                tokens,
2359            );
2360        }
2361    }
2362
2363    /// A step timer fired (`sleep` done, or a budget window opened).
2364    pub(crate) fn on_step_timer(
2365        &mut self,
2366        run_id: &str,
2367        step_id: &str,
2368        budget: bool,
2369        payload: &Value,
2370    ) {
2371        if budget {
2372            if let Some(st) = self
2373                .runs
2374                .get_mut(run_id)
2375                .and_then(|r| r.steps.get_mut(step_id))
2376            {
2377                st.status = StepStatus::Pending;
2378                st.wait = None;
2379            }
2380            if let Some(r) = self.runs.get_mut(run_id) {
2381                r.touch();
2382            }
2383            return;
2384        }
2385        self.finish_step(
2386            run_id,
2387            step_id,
2388            StepStatus::Done,
2389            Some(payload.clone()),
2390            None,
2391            0,
2392        );
2393    }
2394
2395    /// Record a step's terminal outcome; retry / route failures; checkpoint.
2396    pub(crate) fn finish_step_pub(
2397        &mut self,
2398        run_id: &str,
2399        step_id: &str,
2400        status: StepStatus,
2401        output: Option<Value>,
2402        error: Option<String>,
2403        tokens: u64,
2404    ) {
2405        self.finish_step(run_id, step_id, status, output, error, tokens)
2406    }
2407
2408    fn finish_step(
2409        &mut self,
2410        run_id: &str,
2411        step_id: &str,
2412        status: StepStatus,
2413        output: Option<Value>,
2414        error: Option<String>,
2415        tokens: u64,
2416    ) {
2417        // A terminal step may make dependents ready this very iteration — tell
2418        // the loop to re-run scheduling before it parks (the inline fixpoint).
2419        self.resched = true;
2420        let Some(wf) = self
2421            .runs
2422            .get(run_id)
2423            .and_then(|_| self.definition_for_run(run_id))
2424        else {
2425            return;
2426        };
2427        let Some((step, scope)) = self
2428            .runs
2429            .get(run_id)
2430            .and_then(|r| self.resolve_step(&wf, r, step_id))
2431        else {
2432            return;
2433        };
2434        {
2435            let run = self.runs.get_mut(run_id).expect("present");
2436            if run.status.is_terminal() {
2437                return; // a late result for a finished run
2438            }
2439            run.tokens += tokens;
2440        }
2441        crate::obs::metrics::record_step(match status {
2442            StepStatus::Done => "done",
2443            StepStatus::Failed => "failed",
2444            _ => "other",
2445        });
2446        // An output past `limits.inline_max_bytes` is stored as an artifact and
2447        // replaced by a reference, keeping the run record (and every checkpoint
2448        // that serializes it) bounded. A failed artifact write keeps the inline
2449        // value rather than losing the output.
2450        let output = match output {
2451            Some(v) if !v.is_null() => {
2452                let cap = self.settings.limits.inline_max_bytes.unwrap_or(65_536) as usize;
2453                if v.to_string().len() > cap {
2454                    match self.artifacts.create(
2455                        &self.durable,
2456                        super::artifacts::NewArtifact {
2457                            name: &format!("{run_id}/{step_id}/output.json"),
2458                            mime: Some("application/json"),
2459                            content: v.clone(),
2460                            created_by: Some("engine"),
2461                            sensitive: false,
2462                            owner: Some(run_id),
2463                        },
2464                    ) {
2465                        Ok(meta) => {
2466                            self.log.info("step.output.artifact", json!({"run": run_id, "step": step_id, "artifact": meta["id"], "size": meta["size"]}));
2467                            Some(json!({"$artifact": meta["id"], "size": meta["size"]}))
2468                        }
2469                        Err(e) => {
2470                            self.log.warn(
2471                                "step.output.artifact_fail",
2472                                json!({"run": run_id, "step": step_id, "err": e}),
2473                            );
2474                            Some(v)
2475                        }
2476                    }
2477                } else {
2478                    Some(v)
2479                }
2480            }
2481            other => other,
2482        };
2483        // A declared `output_schema` is enforced here: a step that completed
2484        // but produced a shape its consumers cannot parse fails instead.
2485        let (status, error) = match (&status, &step.output_schema, &output) {
2486            (StepStatus::Done, Some(schema), Some(out)) => {
2487                match crate::jsonschema::validate(schema, out) {
2488                    Ok(()) => (status, error),
2489                    Err(e) => (
2490                        StepStatus::Failed,
2491                        Some(format!(
2492                            "output does not match output_schema: {}",
2493                            crate::jsonschema::explain(&e)
2494                        )),
2495                    ),
2496                }
2497            }
2498            _ => (status, error),
2499        };
2500        let attempt = self
2501            .runs
2502            .get(run_id)
2503            .and_then(|r| r.step(step_id))
2504            .map(|s| s.attempt)
2505            .unwrap_or(1);
2506        self.log.info("step.done", json!({"run": run_id, "step": step_id, "status": status, "attempt": attempt, "tokens": tokens, "err": error}));
2507        // Feed the circuit breaker, when this step keeps one. Every ATTEMPT
2508        // counts (a breaker measures calls, not runs) — except our own
2509        // fast-fails: refusing to dial is not evidence about the remote.
2510        if matches!(
2511            step.kind.as_str(),
2512            "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
2513        ) && let Some(cfg) = self.effective_breaker(&step)
2514            && !matches!(status, StepStatus::Failed | StepStatus::Timeout if error
2515                .as_deref()
2516                .is_some_and(|e| e.starts_with(super::breaker::OPEN_ERR)))
2517            && matches!(
2518                status,
2519                StepStatus::Done | StepStatus::Failed | StepStatus::Timeout
2520            )
2521        {
2522            let workflow = self
2523                .runs
2524                .get(run_id)
2525                .map(|r| r.workflow.clone())
2526                .unwrap_or_default();
2527            let key = super::breaker::key(&workflow, step_id);
2528            let mut st = self
2529                .durable
2530                .manifest()
2531                .breakers
2532                .get(&key)
2533                .cloned()
2534                .unwrap_or_else(|| json!({}));
2535            let t = super::breaker::record(&mut st, cfg, status == StepStatus::Done, now_ms());
2536            self.durable.manifest_update(|m| {
2537                m.breakers.insert(key.clone(), st);
2538            });
2539            match t {
2540                super::breaker::Transition::Opened { fails } => self.log.warn(
2541                    "breaker.open",
2542                    json!({"breaker": key, "consecutive_failures": fails,
2543                           "cooldown": format!("{}ms", cfg.cooldown_ms)}),
2544                ),
2545                super::breaker::Transition::Reopened => self.log.warn(
2546                    "breaker.reopen",
2547                    json!({"breaker": key, "probe_failed": true}),
2548                ),
2549                super::breaker::Transition::Closed => {
2550                    self.log.info("breaker.closed", json!({"breaker": key}))
2551                }
2552                super::breaker::Transition::None => {}
2553            }
2554        }
2555        #[cfg(feature = "a2a")]
2556        self.feed_push(
2557            "step",
2558            crate::runtime::a2a_server::FeedVis::Operator,
2559            json!({"run": run_id, "step": step_id, "phase": "done",
2560                   "status": crate::runtime::nested::StatusLabel::as_label(&status), "attempt": attempt, "tokens": tokens,
2561                   // Same 2 KiB cap the run drill-down applies: a step output can
2562                   // be an entire document, and a feed is not the place for it.
2563                   "err": error.as_deref().map(|e| {
2564                       e.chars().take(2048).collect::<String>()
2565                   })}),
2566        );
2567        if matches!(status, StepStatus::Failed | StepStatus::Timeout) {
2568            // Retry?
2569            if let Some(retry) = &step.retry
2570                && attempt <= retry.max
2571            {
2572                // Exponential, with jitter. Without it every step that failed
2573                // in the same wave — the usual case, since they usually failed
2574                // for the same upstream reason — retries in lockstep and
2575                // rebuilds the thundering herd the backoff exists to break up.
2576                // Deterministic per (run, step, attempt): no RNG, so a replay
2577                // reproduces the same schedule.
2578                let base = retry
2579                    .backoff_ms
2580                    .saturating_mul(1u64 << (attempt.saturating_sub(1)).min(10));
2581                let backoff = if base == 0 {
2582                    0
2583                } else {
2584                    let mut h: u64 = 1469598103934665603;
2585                    for b in run_id.bytes().chain(step_id.bytes()).chain([attempt as u8]) {
2586                        h ^= b as u64;
2587                        h = h.wrapping_mul(1099511628211);
2588                    }
2589                    // ±20% around the base.
2590                    let spread = (base / 5).max(1);
2591                    base.saturating_sub(spread) + (h % (spread * 2 + 1))
2592                };
2593                self.log.info("step.retry", json!({"run": run_id, "step": step_id, "attempt": attempt, "backoff_ms": backoff}));
2594                if backoff == 0 {
2595                    if let Some(st) = self
2596                        .runs
2597                        .get_mut(run_id)
2598                        .and_then(|r| r.steps.get_mut(step_id))
2599                    {
2600                        st.status = StepStatus::Pending;
2601                        st.error = error;
2602                    }
2603                } else {
2604                    match self.timers.arm(
2605                        &self.durable,
2606                        now_ms() + backoff,
2607                        json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2608                        Value::Null,
2609                    ) {
2610                        Ok(id) => {
2611                            self.runs.get_mut(run_id).expect("present").suspend_step(
2612                                step_id,
2613                                json!({"kind": "retry_backoff", "timer": id, "error": error}),
2614                            );
2615                        }
2616                        Err(_) => {
2617                            if let Some(st) = self
2618                                .runs
2619                                .get_mut(run_id)
2620                                .and_then(|r| r.steps.get_mut(step_id))
2621                            {
2622                                st.status = StepStatus::Pending;
2623                            }
2624                        }
2625                    }
2626                }
2627                self.checkpoint(false);
2628                return;
2629            }
2630            let err_text = error.clone().unwrap_or_else(|| "failed".into());
2631            self.runs
2632                .get_mut(run_id)
2633                .expect("present")
2634                .end_step(step_id, status, output, error);
2635            // `on_timeout`: a deadline expiring on a wait is usually an
2636            // EXPECTED branch (nobody replied, the alert never cleared), not
2637            // a failure — route to the named step, forced, and keep the run
2638            // alive. Only a real Timeout takes this edge; other errors still
2639            // answer to `on_error`. The step itself stays `Timeout`, which
2640            // does NOT satisfy dependents — so the success path and the
2641            // timeout path are mutually exclusive by construction.
2642            if status == StepStatus::Timeout
2643                && let Some(t) = step.field_str("on_timeout")
2644            {
2645                let target = match &scope {
2646                    Some(sc) => super::nested::scoped_id(&sc.parent, t),
2647                    None => t.to_string(),
2648                };
2649                if let Some(st) = self
2650                    .runs
2651                    .get_mut(run_id)
2652                    .and_then(|r| r.steps.get_mut(&target))
2653                {
2654                    st.status = StepStatus::Pending;
2655                    st.forced = true;
2656                }
2657                self.log.info(
2658                    "step.timeout_routed",
2659                    json!({"run": run_id, "step": step_id, "to": t}),
2660                );
2661                crate::state::kill_point("step.before_done");
2662                self.checkpoint(false);
2663                if scope.is_some() {
2664                    self.on_scoped_step_done(run_id, step_id);
2665                }
2666                return;
2667            }
2668            if let Some(sc) = &scope {
2669                // Inside a body: `continue` marks done-with-error, `goto` re-arms
2670                // a sibling, `fail` leaves the step failed for the parent to judge.
2671                match &step.on_error {
2672                    OnError::Continue => {
2673                        if let Some(st) = self
2674                            .runs
2675                            .get_mut(run_id)
2676                            .and_then(|r| r.steps.get_mut(step_id))
2677                        {
2678                            st.status = StepStatus::Done;
2679                            st.error = Some(err_text.clone());
2680                            if st.output.is_none() {
2681                                st.output = Some(json!({"error": err_text}));
2682                            }
2683                        }
2684                    }
2685                    OnError::Goto(t) => {
2686                        let sid = super::nested::scoped_id(&sc.parent, t);
2687                        if let Some(st) = self
2688                            .runs
2689                            .get_mut(run_id)
2690                            .and_then(|r| r.steps.get_mut(&sid))
2691                        {
2692                            st.status = StepStatus::Pending;
2693                            st.forced = true;
2694                        }
2695                    }
2696                    OnError::Fail => {}
2697                }
2698                crate::state::kill_point("step.before_done");
2699                if !crate::engine::model::pure_data_kind(&step.kind) {
2700                    self.checkpoint(false);
2701                }
2702                self.on_scoped_step_done(run_id, step_id);
2703                return;
2704            }
2705            let routed = run::route_failure(
2706                &wf,
2707                self.runs.get_mut(run_id).expect("present"),
2708                &step,
2709                &err_text,
2710            );
2711            match routed {
2712                Ok(next) => {
2713                    if !next.is_empty() {
2714                        self.log.info(
2715                            "step.goto",
2716                            json!({"run": run_id, "from": step_id, "to": next}),
2717                        );
2718                    }
2719                }
2720                Err(reason) => {
2721                    self.cancel_children_of_run(run_id, "run failed");
2722                    self.runs.get_mut(run_id).expect("present").finish(
2723                        RunStatus::Failed,
2724                        None,
2725                        Some(reason),
2726                    );
2727                    self.on_run_terminal(run_id);
2728                    return;
2729                }
2730            }
2731            if let OnError::Continue = step.on_error {
2732                // Already marked done-with-error by route_failure.
2733            }
2734        } else {
2735            // Memoize (`cache`).
2736            if step.cache.is_some()
2737                && status == StepStatus::Done
2738                && let Some(key) = self
2739                    .runs
2740                    .get(run_id)
2741                    .and_then(|r| r.steps.get(step_id))
2742                    .and_then(|st| st.cache_key.clone())
2743                && let Some(out) = &output
2744            {
2745                self.cache_store(&key, out);
2746            }
2747            self.runs
2748                .get_mut(run_id)
2749                .expect("present")
2750                .end_step(step_id, status, output, error);
2751        }
2752        crate::state::kill_point("step.before_done");
2753        // Pure steps ride the tick's checkpoint — unless this completion made
2754        // the RUN terminal (a routed failure), which must land durably now.
2755        let terminal_now = self
2756            .runs
2757            .get(run_id)
2758            .is_some_and(|r| r.status.is_terminal());
2759        if terminal_now || !crate::engine::model::pure_data_kind(&step.kind) {
2760            self.checkpoint(false);
2761        }
2762        if scope.is_some() {
2763            self.on_scoped_step_done(run_id, step_id);
2764        }
2765    }
2766
2767    /// The run reached a terminal state: report, wake, plan bindings, counters.
2768    /// Evict terminal runs beyond `store.retention.runs`.
2769    ///
2770    /// Without this a long-lived instance keeps one durable record per run
2771    /// forever — on a laptop, the difference between an agent that runs for a
2772    /// month and one that fills a disk. Only TERMINAL runs are candidates:
2773    /// nothing in flight is ever dropped, whatever the policy says. Default is
2774    /// unbounded, so an operator who has not thought about it keeps today's
2775    /// behaviour.
2776    fn evict_terminal_runs(&mut self) {
2777        let policy = &self.settings.store.retention.runs;
2778        let keep_last = policy.keep_last;
2779        let ttl_ms = policy.ttl.as_ref().map(|d| d.0.as_millis() as u64);
2780        if keep_last.is_none() && ttl_ms.is_none() {
2781            return;
2782        }
2783        let now = now_ms();
2784        // Newest first, so "keep the last N" is a prefix.
2785        let mut terminal: Vec<(String, u64)> = self
2786            .runs
2787            .values()
2788            .filter(|r| r.status.is_terminal())
2789            .map(|r| (r.id.clone(), r.finished.unwrap_or(0)))
2790            .collect();
2791        terminal.sort_by_key(|(_, finished)| std::cmp::Reverse(*finished));
2792
2793        let mut drop: Vec<String> = Vec::new();
2794        for (i, (id, finished)) in terminal.iter().enumerate() {
2795            let over_count = keep_last.is_some_and(|k| i >= k as usize);
2796            let over_age = ttl_ms.is_some_and(|t| now.saturating_sub(*finished) > t);
2797            if over_count || over_age {
2798                drop.push(id.clone());
2799            }
2800        }
2801        for id in drop {
2802            // A non-durable run has nothing in the store to evict.
2803            let was_durable = self.runs.get(&id).is_none_or(|r| r.durable);
2804            self.runs.remove(&id);
2805            if was_durable && let Err(e) = self.durable.delete(crate::state::Kind::Run, &id) {
2806                self.log
2807                    .warn("run.evict.fail", json!({"run": id, "err": e.to_string()}));
2808                continue;
2809            }
2810            self.log.info("run.evicted", json!({"run": id}));
2811        }
2812    }
2813
2814    pub(crate) fn on_run_terminal(&mut self, run_id: &str) {
2815        let Some(run) = self.runs.get(run_id) else {
2816            return;
2817        };
2818        let (status, output, error, workflow) = (
2819            run.status,
2820            run.output.clone(),
2821            run.error.clone(),
2822            run.workflow.clone(),
2823        );
2824        #[cfg(feature = "a2a")]
2825        let a2a_task = run.task.clone();
2826        // Eviction runs here because this is the only moment the candidate set
2827        // grows. Deferred to the end of the function so the run's own
2828        // completion handling (webhook reply, A2A task, feed) happens first —
2829        // evicting a record before its result was delivered would be a fine way
2830        // to lose an answer.
2831        let evict_after = true;
2832        // A `respond: sync` webhook awaiting this run gets its result now.
2833        #[cfg(feature = "a2a")]
2834        self.webhook_sync_reply(run_id);
2835        // A queued child run (`child_run` wait) resolves its parent step.
2836        if let Some(parent) = self.runs.get(run_id).and_then(|r| r.parent.clone())
2837            && let (Some(pr), Some(ps)) = (
2838                parent["run"].as_str().map(str::to_string),
2839                parent["step"].as_str().map(str::to_string),
2840            )
2841            && self
2842                .runs
2843                .get(&pr)
2844                .and_then(|r| r.steps.get(&ps))
2845                .is_some_and(|st| {
2846                    st.status == StepStatus::Suspended
2847                        && st.wait.as_ref().is_some_and(|w| w["kind"] == "child_run")
2848                })
2849        {
2850            self.finish_step_pub(
2851                &pr,
2852                &ps,
2853                if status == RunStatus::Completed {
2854                    StepStatus::Done
2855                } else {
2856                    StepStatus::Failed
2857                },
2858                Some(json!({"run": run_id, "status": status, "output": output, "error": error})),
2859                (status != RunStatus::Completed).then(|| {
2860                    error
2861                        .clone()
2862                        .unwrap_or_else(|| format!("child run {}", status.as_str()))
2863                }),
2864                0,
2865            );
2866        }
2867        self.counters.runs_finished += 1;
2868        crate::obs::metrics::record_run(match status {
2869            RunStatus::Completed => crate::obs::metrics::RunOutcome::Completed,
2870            RunStatus::Cancelled => crate::obs::metrics::RunOutcome::Killed,
2871            _ => crate::obs::metrics::RunOutcome::Failed,
2872        });
2873        crate::obs::metrics::record_run_status(status.as_str());
2874        self.log.info("run.done", json!({"run": run_id, "workflow": workflow, "status": status, "err": error, "output": if self.log.content_capture() { output.clone().unwrap_or(Value::Null) } else { Value::Null }}));
2875        self.governor.drop_scope(&format!("run:{run_id}"));
2876        self.retire_sweep();
2877        // Durable-pin GC for the ordinary path: when the LAST run of a
2878        // definition version lands, its stored pin has no reader left. (A
2879        // still-armed workflow re-pins on its next run's first start.)
2880        if let Some(hash) = self.runs.get(run_id).map(|r| r.workflow_hash.clone())
2881            && !self
2882                .runs
2883                .values()
2884                .any(|r| !r.status.is_terminal() && r.workflow_hash == hash)
2885        {
2886            let _ = self.durable.delete(
2887                crate::state::Kind::Memory,
2888                &format!("{}{hash}", super::retire::PIN_PREFIX),
2889            );
2890            self.pin_written.remove(&hash);
2891        }
2892        // Answer waiters (workflow.wait / run sync).
2893        let waiting: Vec<Target> = self
2894            .pending
2895            .iter()
2896            .filter(|p| matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id))
2897            .map(|p| p.target.clone())
2898            .collect();
2899        self.pending
2900            .retain(|p| !matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id));
2901        for t in waiting {
2902            self.reply(
2903                &t,
2904                json!({"run": run_id, "status": status, "output": output, "error": error}),
2905                false,
2906            );
2907        }
2908        // Plan bindings + the root note (wake policy).
2909        let ok = status == RunStatus::Completed;
2910        let note = error
2911            .clone()
2912            .or_else(|| output.as_ref().map(|o| o.to_string()))
2913            .unwrap_or_default();
2914        self.settle_plan_bindings(
2915            &crate::context::plan::Binding::Run {
2916                id: run_id.to_string(),
2917            },
2918            ok,
2919            &note,
2920        );
2921        let wake = self.settings.agent.wake_on();
2922        let notify = match self.settings.agent.on_workflow_finished {
2923            crate::config::v2::OnWorkflowFinished::Ignore => false,
2924            _ => {
2925                ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFinished)
2926                    || !ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFailed)
2927            }
2928        };
2929        if notify && !self.job_shape {
2930            let short = if note.chars().count() > 400 {
2931                format!("{}…", note.chars().take(400).collect::<String>())
2932            } else {
2933                note.clone()
2934            };
2935            let line = format!(
2936                "workflow {workflow} run {run_id} {}: {short}",
2937                status.as_str()
2938            );
2939            match self.settings.agent.on_workflow_finished {
2940                // `note` appends to the root transcript and waits for whatever
2941                // happens next to read it. `think` delivers, which starts a
2942                // turn: the difference between leaving a message and making
2943                // the call. The hop depth continues this run's chain, so a
2944                // workflow the agent started cannot wake it without bound.
2945                crate::config::v2::OnWorkflowFinished::Think => {
2946                    let depth = self.runs.get(run_id).map(|r| r.msg_depth).unwrap_or(0) + 1;
2947                    let cap = self.settings.limits.message_depth();
2948                    if depth > cap {
2949                        self.log.warn(
2950                            "message.too_deep",
2951                            json!({"run": run_id, "reason": "on_workflow_finished",
2952                                   "depth": depth, "max": cap}),
2953                        );
2954                        self.note_root(line);
2955                    } else {
2956                        let principal = self.runs.get(run_id).and_then(|r| r.principal.clone());
2957                        if let Err(e) = self.accept_event(
2958                            kinds::A2A_MESSAGE,
2959                            principal,
2960                            json!({"text": line.clone(), "context_id": crate::context::ROOT,
2961                                   "msg_depth": depth}),
2962                        ) {
2963                            self.log
2964                                .warn("workflow.think.fail", json!({"run": run_id, "err": e}));
2965                            self.note_root(line);
2966                        }
2967                    }
2968                }
2969                _ => self.note_root(line),
2970            }
2971        }
2972        // A `loop` start re-arms the next iteration; `event` start nodes fire on
2973        // workflow.finished/failed.
2974        if let Some((wf, node, spec, kind)) = self.run_start_spec(run_id)
2975            && kind == "loop"
2976        {
2977            self.on_loop_run_finished(
2978                &wf,
2979                &node,
2980                &spec,
2981                ok,
2982                &output.clone().unwrap_or(Value::Null),
2983            );
2984        }
2985        if let Some(ev) = super::starts::run_event(status) {
2986            self.fire_event_starts(
2987                ev,
2988                &json!({"run": run_id, "workflow": workflow, "status": status.as_str()}),
2989            );
2990        }
2991        // A run started over A2A drives its task to the run's outcome.
2992        #[cfg(feature = "a2a")]
2993        if let Some(tid) = &a2a_task {
2994            self.a2a_task_for_run(tid, status.as_str(), output.as_ref(), error.as_deref());
2995        }
2996        self.checkpoint(false);
2997        if evict_after {
2998            self.evict_terminal_runs();
2999        }
3000    }
3001
3002    /// Cancel a run: cancel its children, fail suspended waits, mark cancelled.
3003    pub(crate) fn cancel_run(&mut self, run_id: &str, reason: &str) {
3004        // Cascade to child runs started with `cascade: true` — a cancelled
3005        // parent must not leave its children running unattended.
3006        let kids: Vec<String> = self
3007            .runs
3008            .values()
3009            .filter(|r| {
3010                !r.status.is_terminal()
3011                    && r.parent.as_ref().is_some_and(|p| {
3012                        p["run"].as_str() == Some(run_id) && p["cascade"].as_bool().unwrap_or(true)
3013                    })
3014            })
3015            .map(|r| r.id.clone())
3016            .collect();
3017        for k in kids {
3018            self.cancel_run(&k, "parent run cancelled");
3019        }
3020        self.cancel_children_of_run(run_id, reason);
3021        let timers = self.timers.owned_by(|o| o["run"].as_str() == Some(run_id));
3022        for t in timers {
3023            let _ = self.timers.disarm(&self.durable, &t);
3024        }
3025        self.pending
3026            .retain(|p| !matches!(&p.target, Target::Step(r, _) if r == run_id));
3027        if let Some(r) = self.runs.get_mut(run_id)
3028            && !r.status.is_terminal()
3029        {
3030            r.finish(RunStatus::Cancelled, None, Some(reason.to_string()));
3031            self.on_run_terminal(run_id);
3032        }
3033    }
3034
3035    fn cancel_children_of_run(&mut self, run_id: &str, reason: &str) {
3036        let nodes: Vec<_> = self
3037            .children
3038            .iter()
3039            .filter(|(_, c)| matches!(&c.kind, ChildKind::StepTurn { run, .. } if run == run_id))
3040            .map(|(n, _)| *n)
3041            .collect();
3042        for n in nodes {
3043            self.children.cancel(n, reason);
3044        }
3045    }
3046
3047    // ---- workflow.* tools ------------------------------------------------------
3048
3049    pub(crate) fn workflow_tool(
3050        &mut self,
3051        caller: &ToolCaller,
3052        name: &str,
3053        args: Value,
3054    ) -> ToolOutcome {
3055        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
3056        match name {
3057            "workflow.run" => {
3058                let wname = args["name"].as_str().unwrap_or("").to_string();
3059                let Some(w) = self.workflows.get(&wname) else {
3060                    return err(format!("no such workflow {wname:?}"));
3061                };
3062                if let Some(cause) = self
3063                    .pressure
3064                    .refusal(w.priority == crate::engine::model::Priority::Low)
3065                {
3066                    return err(format!(
3067                        "workflow.run refused: {cause}; retry when it clears"
3068                    ));
3069                }
3070                let start = match args.get("start").and_then(Value::as_str) {
3071                    Some(s) => match w.step(s) {
3072                        Some(st) if st.is_start() => s.to_string(),
3073                        _ => return err(format!("workflow {wname:?} has no start node {s:?}")),
3074                    },
3075                    None => match default_start(w) {
3076                        Some(s) => s,
3077                        None => return err(format!("workflow {wname:?} has no start node")),
3078                    },
3079                };
3080                let wait = args.get("wait").and_then(Value::as_bool).unwrap_or(false);
3081                let timeout_ms = args
3082                    .get("timeout")
3083                    .and_then(Value::as_str)
3084                    .and_then(|t| crate::config::parse_duration(t).ok())
3085                    .map(|d| d.as_millis() as u64)
3086                    .unwrap_or(3_600_000);
3087                let request = match (caller.node, &caller.run, &caller.step) {
3088                    (Some(n), _, _) => {
3089                        json!({"node": n.0, "req": caller.req, "wait": wait, "timeout_ms": timeout_ms})
3090                    }
3091                    (None, Some(r), Some(s)) => {
3092                        json!({"run": r, "step": s, "wait": wait, "timeout_ms": timeout_ms})
3093                    }
3094                    _ => Value::Null,
3095                };
3096                let payload = json!({"workflow": wname, "node": start, "payload": {"requested_by": caller.label_pub()}, "inputs": args.get("inputs").cloned().unwrap_or(json!({})), "request": request, "conversation": caller.ctx, "msg_depth": caller.msg_depth});
3097                match self.accept_event(kinds::WORKFLOW_RUN, caller.principal.clone(), payload) {
3098                    Ok(_) => {
3099                        // Process it right away so the caller learns the run id.
3100                        if let Some(ev) = self.inbox_queue.pop_back() {
3101                            let done = self.on_start_event(&ev);
3102                            if done {
3103                                self.inbox_done(&ev.id);
3104                            }
3105                        }
3106                        // The reply is (or will be) delivered through the request
3107                        // target: immediately with the run id, or when the run
3108                        // finishes for `wait: true` (registered by `on_start_event`).
3109                        ToolOutcome::Executing
3110                    }
3111                    Err(e) => err(e),
3112                }
3113            }
3114            "workflow.list" => ToolOutcome::Ready(
3115                json!({"workflows": self.workflows.values().map(|w| json!({
3116                    "name": w.name, "description": w.description, "armed": w.armed, "hash": w.hash,
3117                    "starts": w.start_steps().iter().map(|s| json!({"node": s.id, "kind": s.kind})).collect::<Vec<_>>(),
3118                    "runs": self.runs.values().filter(|r| r.workflow == w.name).map(|r| json!({"id": r.id, "status": r.status})).collect::<Vec<_>>(),
3119                })).collect::<Vec<_>>()}),
3120                false,
3121            ),
3122            "workflow.status" => {
3123                let runs: Vec<Value> = match (
3124                    args.get("run").and_then(Value::as_str),
3125                    args.get("name").and_then(Value::as_str),
3126                ) {
3127                    (Some(id), _) => self
3128                        .runs
3129                        .get(id)
3130                        .map(|r| vec![run_detail(r)])
3131                        .unwrap_or_default(),
3132                    (None, Some(n)) => self
3133                        .runs
3134                        .values()
3135                        .filter(|r| r.workflow == n)
3136                        .map(RunState::summary)
3137                        .collect(),
3138                    _ => self.runs.values().map(RunState::summary).collect(),
3139                };
3140                ToolOutcome::Ready(json!({"runs": runs}), false)
3141            }
3142            "workflow.cancel" => {
3143                let id = args["run"].as_str().unwrap_or("").to_string();
3144                if !self.runs.contains_key(&id) {
3145                    return err(format!("no such run {id:?}"));
3146                }
3147                let reason = args
3148                    .get("reason")
3149                    .and_then(Value::as_str)
3150                    .unwrap_or("cancelled by request")
3151                    .to_string();
3152                self.cancel_run(&id, &reason);
3153                ToolOutcome::Ready(
3154                    json!({"ok": true, "status": self.runs.get(&id).map(|r| r.status.as_str()).unwrap_or("cancelled")}),
3155                    false,
3156                )
3157            }
3158            "workflow.wait" => {
3159                let id = args["run"].as_str().unwrap_or("").to_string();
3160                let timeout_ms = args
3161                    .get("timeout")
3162                    .and_then(Value::as_str)
3163                    .and_then(|t| crate::config::parse_duration(t).ok())
3164                    .map(|d| d.as_millis() as u64)
3165                    .unwrap_or(3_600_000);
3166                match self.runs.get(&id) {
3167                    None => err(format!("no such run {id:?}")),
3168                    Some(r) if r.status.is_terminal() => ToolOutcome::Ready(
3169                        json!({"run": id, "status": r.status, "output": r.output, "error": r.error}),
3170                        false,
3171                    ),
3172                    Some(_) => ToolOutcome::Deferred(PendingKind::Run {
3173                        run: id,
3174                        deadline_ms: now_ms() + timeout_ms,
3175                    }),
3176                }
3177            }
3178            "workflow.pause" | "workflow.resume" => {
3179                let pause = name == "workflow.pause";
3180                // `before_step`: pause the run the moment a named step is about
3181                // to start, rather than immediately. This is a breakpoint —
3182                // "stop when you reach `notify`" — which is what you actually
3183                // want when debugging a graph, and it needs no new surface
3184                // because pause already exists and already survives a restart.
3185                if pause
3186                    && let Some(id) = args.get("run").and_then(Value::as_str)
3187                    && let Some(step) = args.get("before_step").and_then(Value::as_str)
3188                {
3189                    let known = self
3190                        .definition_for_run(id)
3191                        .is_some_and(|wf| wf.steps.contains_key(step));
3192                    if !known {
3193                        return err(format!(
3194                            "before_step {step:?} is not a step of this run's workflow"
3195                        ));
3196                    }
3197                    match self.runs.get_mut(id) {
3198                        None => return err(format!("no such run {id:?}")),
3199                        Some(r) => {
3200                            r.break_before = Some(step.to_string());
3201                            r.dirty = true;
3202                        }
3203                    }
3204                    self.log
3205                        .info("run.breakpoint", json!({"run": id, "before_step": step}));
3206                    return ToolOutcome::Ready(json!({"run": id, "break_before": step}), false);
3207                }
3208                if let Some(id) = args.get("run").and_then(Value::as_str) {
3209                    match self.runs.get_mut(id) {
3210                        None => return err(format!("no such run {id:?}")),
3211                        Some(r) if r.status.is_terminal() => {
3212                            return err(format!("run {id:?} is already {}", r.status.as_str()));
3213                        }
3214                        Some(r) => {
3215                            r.status = if pause {
3216                                RunStatus::Paused
3217                            } else {
3218                                RunStatus::Running
3219                            };
3220                            r.touch();
3221                        }
3222                    }
3223                    return ToolOutcome::Ready(json!({"ok": true}), false);
3224                }
3225                if let Some(n) = args.get("name").and_then(Value::as_str) {
3226                    match self.workflows.get_mut(n) {
3227                        None => return err(format!("no such workflow {n:?}")),
3228                        // The definitions are shared (`Arc`) on the hot path;
3229                        // arming is the one mutation, and it is rare —
3230                        // copy-on-write is the honest cost here.
3231                        Some(w) => std::sync::Arc::make_mut(w).armed = !pause,
3232                    }
3233                    if !pause {
3234                        self.arm_workflows();
3235                    }
3236                    return ToolOutcome::Ready(json!({"ok": true}), false);
3237                }
3238                err(format!("{name}: give run or name"))
3239            }
3240            "workflow.create" | "workflow.update" => {
3241                // Workflows are STANDING instructions — what the agent does
3242                // when a schedule fires or a webhook lands, unattended. An
3243                // agent that can rewrite them changes what happens next time,
3244                // and the change outlives the conversation that caused it.
3245                if let Some(e) = self.workflows_locked(name) {
3246                    return e;
3247                }
3248                let def = args["definition"].clone();
3249                match parse_workflow(&def) {
3250                    Err(e) => err(format!("{name}: {}", e.join("; "))),
3251                    Ok(w) if w.tool.is_some() => err(format!(
3252                        "{name}: a `tool:` block may only be declared in the startup config.                          The tool registry is built once and validated fail-closed; minting                          or shadowing a tool name at runtime would put no operator in the                          loop. (workflow {:?})",
3253                        w.name
3254                    )),
3255                    Ok(mut w) => {
3256                        self.fill_durable_default(&mut w);
3257                        if name == "workflow.create" && self.workflows.contains_key(&w.name) {
3258                            return err(format!(
3259                                "workflow {:?} exists (use workflow.update)",
3260                                w.name
3261                            ));
3262                        }
3263                        if name == "workflow.update" && !self.workflows.contains_key(&w.name) {
3264                            return err(format!(
3265                                "workflow {:?} does not exist (use workflow.create)",
3266                                w.name
3267                            ));
3268                        }
3269                        let (wname, hash) = (w.name.clone(), w.hash.clone());
3270                        // Durable definition (memory/_workflows/<name>).
3271                        let rec = crate::context::memory::Record {
3272                            value: def,
3273                            ts: now_ms(),
3274                            ttl_ms: None,
3275                            by: Some(caller.label_pub()),
3276                        };
3277                        if let Err(e) = self.durable.put(
3278                            Kind::Memory,
3279                            &format!("{WORKFLOW_DEF_PREFIX}{wname}"),
3280                            serde_json::to_value(&rec).unwrap_or(Value::Null),
3281                            None,
3282                        ) {
3283                            return err(format!("{name}: store: {e}"));
3284                        }
3285                        let arm = args.get("arm").and_then(Value::as_bool).unwrap_or(true);
3286                        let mut w = w;
3287                        w.armed = arm;
3288                        self.workflows.insert(wname.clone(), std::sync::Arc::new(w));
3289                        self.log.info(
3290                            "workflow.defined",
3291                            json!({"name": wname, "hash": &hash[..12], "op": name}),
3292                        );
3293                        if arm {
3294                            self.arm_workflows();
3295                        }
3296                        ToolOutcome::Ready(
3297                            json!({"name": wname, "hash": hash, "armed": arm}),
3298                            false,
3299                        )
3300                    }
3301                }
3302            }
3303            "workflow.delete" => {
3304                if let Some(e) = self.workflows_locked(name) {
3305                    return e;
3306                }
3307                let wname = args["name"].as_str().unwrap_or("").to_string();
3308                let Some(wf) = self.workflows.remove(&wname) else {
3309                    return err(format!("no such workflow {wname:?}"));
3310                };
3311                let _ = self
3312                    .durable
3313                    .delete(Kind::Memory, &format!("{WORKFLOW_DEF_PREFIX}{wname}"));
3314                // Retire rather than drop: retirement pins the definition so
3315                // live runs keep resolving `definition_for_run` mid-flight, and
3316                // applies the workflow's `unload:` policy to them. Delete means
3317                // "stop being a workflow", not "strand whatever is in flight".
3318                self.retire_workflow(&wf, "deleted");
3319                self.log.info("workflow.deleted", json!({"name": wname}));
3320                ToolOutcome::Ready(json!({"ok": true}), false)
3321            }
3322            "workflow.signal" => {
3323                let sname = args["name"].as_str().unwrap_or("").to_string();
3324                let _ = self.accept_event(kinds::SIGNAL, caller.principal.clone(), json!({"name": sname, "payload": args.get("payload").cloned().unwrap_or(Value::Null), "run": args.get("run"), "from": caller.label_pub()}));
3325                // The signal goes on the durable inbox; waits and `signal`
3326                // start nodes are woken when the loop drains it, so no
3327                // delivery count is available at this point.
3328                ToolOutcome::Ready(
3329                    json!({"delivered": 0, "note": "signal recorded; waits and signal start nodes are woken when the loop drains the inbox"}),
3330                    false,
3331                )
3332            }
3333            _ => err(format!("unknown workflow tool {name}")),
3334        }
3335    }
3336}
3337
3338impl ToolCaller {
3339    pub(crate) fn label_pub(&self) -> String {
3340        if let Some(s) = &self.subagent {
3341            return format!("subagent:{s}");
3342        }
3343        if let (Some(r), Some(s)) = (&self.run, &self.step) {
3344            return format!("step:{r}/{s}");
3345        }
3346        format!(
3347            "ctx:{}",
3348            self.ctx.as_deref().unwrap_or(crate::context::ROOT)
3349        )
3350    }
3351}
3352
3353/// The start node `workflow.run` uses by default: `manual`, else the first.
3354fn default_start(w: &Workflow) -> Option<String> {
3355    let starts = w.start_steps();
3356    starts
3357        .iter()
3358        .find(|s| s.kind == "manual")
3359        .or_else(|| starts.first())
3360        .map(|s| s.id.clone())
3361}
3362
3363fn node_kind<'a>(w: &'a Workflow, node: &str) -> Option<&'a str> {
3364    w.step(node).map(|s| s.kind.as_str())
3365}
3366
3367fn run_detail(r: &RunState) -> Value {
3368    let mut v = r.summary();
3369    v["step_states"] = json!(r.steps);
3370    v["vars"] = Value::Object(r.vars.clone());
3371    v
3372}
3373
3374/// The `memory.<key>` roots a value's templates reference.
3375fn collect_memory_keys(v: &Value, out: &mut Vec<String>) {
3376    match v {
3377        Value::String(s) => {
3378            let mut rest = s.as_str();
3379            while let Some(i) = rest.find("memory.") {
3380                let after = &rest[i + "memory.".len()..];
3381                let key: String = after
3382                    .chars()
3383                    .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/' | ':'))
3384                    .collect();
3385                if !key.is_empty() && !out.contains(&key) {
3386                    out.push(key.clone());
3387                }
3388                rest = &after[key.len().min(after.len())..];
3389            }
3390        }
3391        Value::Array(a) => a.iter().for_each(|x| collect_memory_keys(x, out)),
3392        Value::Object(o) => o.values().for_each(|x| collect_memory_keys(x, out)),
3393        _ => {}
3394    }
3395}
3396
3397/// Expand a workflow directory into the files it contains.
3398///
3399/// `pattern` is a comma-separated list of shell-style globs relative to `dir`.
3400/// `**` crosses directory boundaries, so `**/*.yaml` walks the tree and
3401/// `*.yaml` does not — the distinction people already expect from every other
3402/// tool that takes a glob.
3403///
3404/// Results are SORTED. A directory listing is in whatever order the filesystem
3405/// feels like, and load order decides which of two same-named workflows is
3406/// reported as the duplicate — a diagnostic that changed between machines would
3407/// be worse than useless.
3408fn expand_dir(dir: &str, pattern: &str) -> Result<Vec<String>, String> {
3409    let root = std::path::Path::new(dir);
3410    if !root.is_dir() {
3411        return Err(format!("not a directory ({})", root.display()));
3412    }
3413    let pats: Vec<&str> = pattern
3414        .split(',')
3415        .map(str::trim)
3416        .filter(|p| !p.is_empty())
3417        .collect();
3418    let recursive = pats.iter().any(|p| p.contains("**"));
3419    let mut out = Vec::new();
3420    let mut stack = vec![root.to_path_buf()];
3421    while let Some(d) = stack.pop() {
3422        let rd = std::fs::read_dir(&d).map_err(|e| e.to_string())?;
3423        for ent in rd.flatten() {
3424            let path = ent.path();
3425            if path.is_dir() {
3426                if recursive {
3427                    stack.push(path);
3428                }
3429                continue;
3430            }
3431            let rel = path.strip_prefix(root).unwrap_or(&path);
3432            let rels = rel.to_string_lossy();
3433            if pats.iter().any(|p| glob_match(p, &rels)) {
3434                out.push(path.to_string_lossy().into_owned());
3435            }
3436        }
3437    }
3438    out.sort();
3439    Ok(out)
3440}
3441
3442/// Shell-style glob matching: `*` within a segment, `**` across segments, `?`
3443/// for one character. Small on purpose — a workflow directory does not need
3444/// brace expansion or character classes, and a dependency for this would be a
3445/// poor trade in a tree that counts them.
3446fn glob_match(pat: &str, text: &str) -> bool {
3447    // `**/x` should also match a bare `x` at the root: people write it meaning
3448    // "at any depth", which includes none.
3449    if let Some(rest) = pat.strip_prefix("**/")
3450        && glob_match(rest, text)
3451    {
3452        return true;
3453    }
3454    let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), text.chars().collect());
3455    fn go(p: &[char], t: &[char]) -> bool {
3456        match p.first() {
3457            None => t.is_empty(),
3458            Some('*') => {
3459                let doubled = p.get(1) == Some(&'*');
3460                let rest = if doubled { &p[2..] } else { &p[1..] };
3461                // A single `*` stops at a separator; `**` does not.
3462                let mut i = 0;
3463                loop {
3464                    if go(rest, &t[i..]) {
3465                        return true;
3466                    }
3467                    if i >= t.len() {
3468                        return false;
3469                    }
3470                    if !doubled && t[i] == '/' {
3471                        return false;
3472                    }
3473                    i += 1;
3474                }
3475            }
3476            Some('?') if !t.is_empty() => go(&p[1..], &t[1..]),
3477            Some(c) if t.first() == Some(c) => go(&p[1..], &t[1..]),
3478            _ => false,
3479        }
3480    }
3481    go(&p, &t)
3482}
3483
3484#[cfg(test)]
3485mod glob_tests {
3486    use super::glob_match;
3487
3488    #[test]
3489    fn a_single_star_stays_inside_one_segment_and_double_crosses() {
3490        // The distinction people expect from every other tool that takes a glob.
3491        assert!(glob_match("*.yaml", "nightly.yaml"));
3492        assert!(
3493            !glob_match("*.yaml", "team/nightly.yaml"),
3494            "* must not cross /"
3495        );
3496        assert!(glob_match("**/*.yaml", "team/nightly.yaml"));
3497        assert!(glob_match("**/*.yaml", "a/b/c/deep.yaml"));
3498        // `**/x` means "at any depth", and no depth is a depth — otherwise a
3499        // recursive pattern silently skips the files at the root.
3500        assert!(glob_match("**/*.yaml", "nightly.yaml"));
3501
3502        assert!(glob_match("flows/*.json", "flows/a.json"));
3503        assert!(!glob_match("flows/*.json", "flows/a.yaml"));
3504        assert!(!glob_match("*.yaml", "yaml"), "the dot is literal");
3505        assert!(glob_match("?.yaml", "a.yaml"));
3506        assert!(!glob_match("?.yaml", "ab.yaml"));
3507        // A pattern with no wildcard is an exact name.
3508        assert!(glob_match("nightly.yaml", "nightly.yaml"));
3509        assert!(!glob_match("nightly.yaml", "nightly.yml"));
3510    }
3511}