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                            self.finish_step(
1423                                run_id,
1424                                step_id,
1425                                StepStatus::Done,
1426                                Some(json!({"id": id, "seq": seq, "stream": stream,
1427                                           "subject": subject})),
1428                                None,
1429                                0,
1430                            );
1431                        }
1432                        Err(e) => {
1433                            self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1434                        }
1435                    }
1436                    return;
1437                }
1438                if let Some(n) = spec.get("note").and_then(Value::as_str) {
1439                    let text = format!("run {run_id}: {n}");
1440                    self.note_root(text);
1441                }
1442                if let Some(a) = spec.get("audit") {
1443                    self.log.info(
1444                        "audit.emit",
1445                        json!({"run": run_id, "step": step_id, "audit": a}),
1446                    );
1447                }
1448                self.finish_step(
1449                    run_id,
1450                    step_id,
1451                    StepStatus::Done,
1452                    spec.get("value").cloned().or(Some(Value::Null)),
1453                    None,
1454                    0,
1455                );
1456            }
1457            "finish" => {
1458                let status = match spec
1459                    .get("status")
1460                    .and_then(Value::as_str)
1461                    .unwrap_or("completed")
1462                {
1463                    "completed" => RunStatus::Completed,
1464                    "refused" => RunStatus::Refused,
1465                    "cancelled" => RunStatus::Cancelled,
1466                    _ => RunStatus::Failed,
1467                };
1468                let output = spec.get("output").cloned();
1469                // `outputs.schema` was checked for well-formedness at parse time
1470                // and then never applied — a workflow could declare the shape of
1471                // its result and return anything at all. Enforce it here, where
1472                // the result actually exists. A completed run whose output does
1473                // not match what it promised is a FAILED run: a caller reading
1474                // the declared shape is the whole reason to declare one.
1475                if matches!(status, RunStatus::Completed)
1476                    && let Some(schema) = self
1477                        .definition_for_run(run_id)
1478                        .and_then(|wf| wf.outputs_schema.clone())
1479                {
1480                    let value = output.clone().unwrap_or(Value::Null);
1481                    if let Err(errs) = crate::jsonschema::validate(&schema, &value) {
1482                        self.finish_step_pub(
1483                            run_id,
1484                            step_id,
1485                            StepStatus::Failed,
1486                            None,
1487                            Some(format!(
1488                                "finish: output does not match the workflow's declared \
1489                                 outputs.schema: {}",
1490                                errs.join("; ")
1491                            )),
1492                            0,
1493                        );
1494                        return;
1495                    }
1496                }
1497                let reason = spec
1498                    .get("reason")
1499                    .and_then(Value::as_str)
1500                    .map(str::to_string);
1501                self.runs.get_mut(run_id).expect("present").end_step(
1502                    step_id,
1503                    StepStatus::Done,
1504                    output.clone(),
1505                    None,
1506                );
1507                self.runs
1508                    .get_mut(run_id)
1509                    .expect("present")
1510                    .finish(status, output, reason);
1511                self.on_run_terminal(run_id);
1512            }
1513            "sleep" => {
1514                let ms = spec
1515                    .get("duration")
1516                    .map(crate::engine::model::duration_ms)
1517                    .unwrap_or(Ok(0))
1518                    .unwrap_or(0);
1519                match self.timers.arm(
1520                    &self.durable,
1521                    now_ms() + ms,
1522                    json!({"kind": "step", "run": run_id, "step": step_id}),
1523                    json!({"slept_ms": ms}),
1524                ) {
1525                    Ok(id) => {
1526                        self.runs.get_mut(run_id).expect("present").suspend_step(
1527                            step_id,
1528                            json!({"kind": "sleep", "timer": id, "deadline_ms": now_ms() + ms}),
1529                        );
1530                        self.checkpoint(false);
1531                    }
1532                    Err(e) => self.finish_step(
1533                        run_id,
1534                        step_id,
1535                        StepStatus::Failed,
1536                        None,
1537                        Some(format!("sleep: {e}")),
1538                        0,
1539                    ),
1540                }
1541            }
1542            "tool" => {
1543                let name = spec
1544                    .get("name")
1545                    .and_then(Value::as_str)
1546                    .unwrap_or("")
1547                    .to_string();
1548                let args = spec.get("args").cloned().unwrap_or(json!({}));
1549                self.step_tool_call(run_id, step_id, &step_caller, &name, args);
1550            }
1551            "http" => self.step_http(run_id, step_id, &spec),
1552            k if k.starts_with("memory.")
1553                || k.starts_with("artifact.")
1554                || k.starts_with("knowledge.")
1555                || k.starts_with("search.") =>
1556            {
1557                let mut args = spec.clone();
1558                // `ttl` etc. pass through as-is; the contract validates.
1559                args.retain(|_, v| !v.is_null());
1560                self.step_tool_call(run_id, step_id, &step_caller, k, Value::Object(args));
1561            }
1562            "mcp.tool" => {
1563                let server = spec
1564                    .get("server")
1565                    .and_then(Value::as_str)
1566                    .unwrap_or("")
1567                    .to_string();
1568                let tool = spec
1569                    .get("tool")
1570                    .and_then(Value::as_str)
1571                    .unwrap_or("")
1572                    .to_string();
1573                let args = spec.get("args").cloned().unwrap_or(json!({}));
1574                // Pace calls toward a rated catalog service. A dry bucket fails
1575                // the step — a refusal the workflow's `retry:` can absorb —
1576                // rather than blocking the single-writer loop.
1577                if let Err(e) = self.service_rate_take(&server) {
1578                    self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0);
1579                    return;
1580                }
1581                let Some(client) = self.mcp.get(&server).cloned() else {
1582                    self.finish_step(
1583                        run_id,
1584                        step_id,
1585                        StepStatus::Failed,
1586                        None,
1587                        Some(format!("mcp server {server:?} is not connected")),
1588                        0,
1589                    );
1590                    return;
1591                };
1592                // The key must NOT vary by attempt: a retry that presents a
1593                // fresh key is exactly the duplicate the key exists to prevent,
1594                // so nothing per-attempt may enter it.
1595                // The attempt rides separately for servers that want to
1596                // OBSERVE retries without keying on them, and `idempotency:
1597                // {value: …}` substitutes an application-level key (an order
1598                // id) when one exists — which beats any run-derived key, since
1599                // it also collides two different RUNS attempting the same
1600                // real-world operation.
1601                let key = spec
1602                    .get("idempotency")
1603                    .and_then(|i| i.get("value"))
1604                    .and_then(Value::as_str)
1605                    .map(str::to_string)
1606                    .unwrap_or_else(|| crate::engine::run::idempotency_key(run_id, step_id));
1607                let meta = json!({"agent/idempotency_key": key, "agent/attempt": attempt, "agent/instance": self.instance, "agent/run": run_id});
1608                let timeout = step
1609                    .timeout_ms
1610                    .map(std::time::Duration::from_millis)
1611                    .unwrap_or(
1612                        self.settings
1613                            .limits
1614                            .step_timeout
1615                            .map(|d| d.0)
1616                            .unwrap_or(std::time::Duration::from_secs(600)),
1617                    );
1618                let tx = self.events_tx.clone();
1619                let (r, s) = (run_id.to_string(), step_id.to_string());
1620                self.executing
1621                    .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1622                std::thread::Builder::new()
1623                    .name(format!("step:{server}.{tool}"))
1624                    .spawn(move || {
1625                        let (output, is_error, error) = match client.call_tool_with_meta_within(
1626                            &tool,
1627                            Some(args),
1628                            meta,
1629                            timeout,
1630                        ) {
1631                            Ok(res) => {
1632                                let v = super::worker::tool_result_value(&res);
1633                                if res.is_error() {
1634                                    (v.clone(), true, Some(res.text()))
1635                                } else {
1636                                    (v, false, None)
1637                                }
1638                            }
1639                            Err(e) => (Value::Null, true, Some(format!("transport error: {e}"))),
1640                        };
1641                        let _ = tx.send(super::events::Event::StepDone {
1642                            run: r,
1643                            step: s,
1644                            output,
1645                            is_error,
1646                            error,
1647                            tokens: 0,
1648                        });
1649                    })
1650                    .ok();
1651            }
1652            "agent" | "think" => self.step_turn(run_id, step_id, &step, &spec, &data),
1653            "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph" => {
1654                self.nested_start(run_id, step_id, &step, &spec)
1655            }
1656            "wait" | "join" | "workflow" | "message" | "workflow.signal" | "workflow.wait"
1657            | "workflow.cancel" | "subagent" | "human" | "mcp.resource" | "a2a.delegate"
1658            | "a2a.send" | "a2a.wait" | "classify" | "extract" | "summarize" | "judge"
1659            | "route" => {
1660                self.execute_orchestration_step(run_id, step_id, &step, &spec, &data, &step_caller)
1661            }
1662            "switch" => {
1663                let on = spec.get("on").cloned().unwrap_or(Value::Null);
1664                let key = match &on {
1665                    Value::String(x) => x.clone(),
1666                    other => other.to_string(),
1667                };
1668                let cases = step
1669                    .field("cases")
1670                    .and_then(Value::as_object)
1671                    .cloned()
1672                    .unwrap_or_default();
1673                let target = cases
1674                    .get(&key)
1675                    .and_then(Value::as_str)
1676                    .map(str::to_string)
1677                    .or_else(|| step.field_str("default").map(str::to_string));
1678                match target {
1679                    Some(t) => {
1680                        // The chosen case runs even without its deps being terminal
1681                        // (an explicit routing edge); the other cases are skipped.
1682                        let scope_prefix = step_id
1683                            .rsplit_once('.')
1684                            .map(|(p, _)| format!("{p}."))
1685                            .unwrap_or_default();
1686                        let mut skipped = Vec::new();
1687                        // Every other target (cases + default) still pending is skipped;
1688                        // the chosen one is forced (runs even without its deps).
1689                        let mut others: Vec<String> = cases
1690                            .values()
1691                            .filter_map(Value::as_str)
1692                            .map(str::to_string)
1693                            .collect();
1694                        if let Some(d) = step.field_str("default") {
1695                            others.push(d.to_string());
1696                        }
1697                        if let Some(run) = self.runs.get_mut(run_id) {
1698                            for tid in others {
1699                                if tid == t {
1700                                    continue;
1701                                }
1702                                let sid = format!("{scope_prefix}{tid}");
1703                                if let Some(st) = run.steps.get_mut(&sid)
1704                                    && st.status == StepStatus::Pending
1705                                {
1706                                    // Pruned, not skipped: the case was not
1707                                    // chosen, so its whole tail is dead.
1708                                    st.status = StepStatus::Pruned;
1709                                    skipped.push(sid);
1710                                }
1711                            }
1712                            let sid = format!("{scope_prefix}{t}");
1713                            if let Some(st) = run.steps.get_mut(&sid) {
1714                                st.status = StepStatus::Pending;
1715                                st.forced = true;
1716                            }
1717                        }
1718                        self.finish_step(
1719                            run_id,
1720                            step_id,
1721                            StepStatus::Done,
1722                            Some(json!({"case": key, "goto": t, "skipped": skipped})),
1723                            None,
1724                            0,
1725                        );
1726                    }
1727                    // No case, no default: `on_no_match: skip` prunes every
1728                    // branch and completes — for a switch whose "else" is
1729                    // honestly "do nothing". The default stays fail-closed.
1730                    None if step.field_str("on_no_match") == Some("skip") => {
1731                        let scope_prefix = step_id
1732                            .rsplit_once('.')
1733                            .map(|(p, _)| format!("{p}."))
1734                            .unwrap_or_default();
1735                        let mut skipped = Vec::new();
1736                        let others: Vec<String> = cases
1737                            .values()
1738                            .filter_map(Value::as_str)
1739                            .map(str::to_string)
1740                            .collect();
1741                        if let Some(run) = self.runs.get_mut(run_id) {
1742                            for tid in others {
1743                                let sid = format!("{scope_prefix}{tid}");
1744                                if let Some(st) = run.steps.get_mut(&sid)
1745                                    && st.status == StepStatus::Pending
1746                                {
1747                                    st.status = StepStatus::Pruned;
1748                                    skipped.push(sid);
1749                                }
1750                            }
1751                        }
1752                        self.finish_step(
1753                            run_id,
1754                            step_id,
1755                            StepStatus::Done,
1756                            Some(json!({"case": key, "matched": false, "skipped": skipped})),
1757                            None,
1758                            0,
1759                        );
1760                    }
1761                    None => self.finish_step(
1762                        run_id,
1763                        step_id,
1764                        StepStatus::Failed,
1765                        Some(json!({"case": key})),
1766                        Some(format!("switch: no case for {key:?} and no default")),
1767                        0,
1768                    ),
1769                }
1770            }
1771            "map" | "filter" | "reduce" | "sort" | "dedupe" | "chunk" | "parse" => {
1772                let out = match step.kind.as_str() {
1773                    "map" => crate::engine::data::map(
1774                        spec.get("over").unwrap_or(&Value::Null),
1775                        step.field_str("expr").unwrap_or(""),
1776                        step.field_str("as").unwrap_or("item"),
1777                        &data,
1778                    ),
1779                    "filter" => crate::engine::data::filter(
1780                        spec.get("over").unwrap_or(&Value::Null),
1781                        step.field_str("expr").unwrap_or(""),
1782                        step.field_str("as").unwrap_or("item"),
1783                        &data,
1784                    ),
1785                    "reduce" => crate::engine::data::reduce(
1786                        spec.get("over").unwrap_or(&Value::Null),
1787                        step.field_str("expr").unwrap_or(""),
1788                        spec.get("initial").cloned().unwrap_or(Value::Null),
1789                        step.field_str("as").unwrap_or("item"),
1790                        step.field_str("acc").unwrap_or("acc"),
1791                        &data,
1792                    ),
1793                    "sort" => crate::engine::data::sort(
1794                        spec.get("over").unwrap_or(&Value::Null),
1795                        spec.get("by").and_then(Value::as_str),
1796                        spec.get("order").and_then(Value::as_str),
1797                    ),
1798                    "dedupe" => crate::engine::data::dedupe(
1799                        spec.get("over").unwrap_or(&Value::Null),
1800                        spec.get("by").and_then(Value::as_str),
1801                    ),
1802                    "chunk" => crate::engine::data::chunk(
1803                        spec.get("value").unwrap_or(&Value::Null),
1804                        spec.get("by").and_then(Value::as_str),
1805                        spec.get("size").and_then(Value::as_u64).unwrap_or(0) as usize,
1806                        spec.get("overlap").and_then(Value::as_u64).unwrap_or(0) as usize,
1807                    ),
1808                    _ => crate::engine::data::parse(
1809                        spec.get("text").and_then(Value::as_str).unwrap_or(""),
1810                        spec.get("format").and_then(Value::as_str),
1811                    ),
1812                };
1813                match out {
1814                    Ok(v) => self.finish_step(run_id, step_id, StepStatus::Done, Some(v), None, 0),
1815                    Err(e) => {
1816                        self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(e), 0)
1817                    }
1818                }
1819            }
1820            other => self.finish_step(
1821                run_id,
1822                step_id,
1823                StepStatus::Failed,
1824                None,
1825                Some(format!(
1826                    "step kind {other:?} is not executable in this build"
1827                )),
1828                0,
1829            ),
1830        }
1831    }
1832
1833    /// A step's internal tool call (in-loop or deferred/executor).
1834    fn step_tool_call(
1835        &mut self,
1836        run_id: &str,
1837        step_id: &str,
1838        caller: &ToolCaller,
1839        name: &str,
1840        args: Value,
1841    ) {
1842        match self.execute_tool(caller, name, args) {
1843            ToolOutcome::Ready(v, is_error) => {
1844                let err = is_error.then(|| match &v {
1845                    Value::String(s) => s.clone(),
1846                    o => o.to_string(),
1847                });
1848                self.finish_step(
1849                    run_id,
1850                    step_id,
1851                    if is_error {
1852                        StepStatus::Failed
1853                    } else {
1854                        StepStatus::Done
1855                    },
1856                    Some(v),
1857                    err,
1858                    0,
1859                );
1860            }
1861            ToolOutcome::Deferred(kind) => {
1862                let wait = match &kind {
1863                    PendingKind::Timer { id } => json!({"kind": "timer", "timer": id}),
1864                    PendingKind::Subagent { handle } => {
1865                        json!({"kind": "subagent", "handle": handle})
1866                    }
1867                    PendingKind::Think { .. } => json!({"kind": "think"}),
1868                    PendingKind::Run { run, .. } => json!({"kind": "run", "run": run}),
1869                    PendingKind::Await {
1870                        condition,
1871                        deadline_ms,
1872                    } => {
1873                        json!({"kind": "await", "condition": condition, "deadline_ms": deadline_ms})
1874                    }
1875                    PendingKind::Human {
1876                        task, deadline_ms, ..
1877                    } => {
1878                        json!({"kind": "human", "task": task, "deadline_ms": deadline_ms})
1879                    }
1880                };
1881                self.runs
1882                    .get_mut(run_id)
1883                    .expect("present")
1884                    .suspend_step(step_id, wait);
1885                if !matches!(kind, PendingKind::Timer { .. }) {
1886                    self.push_pending(super::reactor::PendingTool {
1887                        target: Target::Step(run_id.to_string(), step_id.to_string()),
1888                        name: name.to_string(),
1889                        kind,
1890                        started_ms: now_ms(),
1891                    });
1892                }
1893                self.checkpoint(false);
1894            }
1895            ToolOutcome::Executing => {
1896                self.executing
1897                    .insert(format!("{run_id}/{step_id}"), std::time::Instant::now());
1898            }
1899        }
1900    }
1901
1902    pub(crate) fn step_turn_pub(
1903        &mut self,
1904        run_id: &str,
1905        step_id: &str,
1906        step: &Step,
1907        spec: &Map<String, Value>,
1908        data: &template::Data,
1909    ) {
1910        self.step_turn(run_id, step_id, step, spec, data)
1911    }
1912
1913    /// An `agent`/`think` step → a turn worker (budget-admitted).
1914    fn step_turn(
1915        &mut self,
1916        run_id: &str,
1917        step_id: &str,
1918        step: &Step,
1919        spec: &Map<String, Value>,
1920        data: &template::Data,
1921    ) {
1922        let is_think = step.kind == "think";
1923        let prompt = if is_think {
1924            spec.get("prompt").and_then(Value::as_str).unwrap_or("")
1925        } else {
1926            spec.get("instruction")
1927                .and_then(Value::as_str)
1928                .unwrap_or("")
1929        }
1930        .to_string();
1931        let output_schema = spec.get("output_schema").cloned();
1932        let mut messages = Vec::new();
1933        // `reads`: fold named run data into the prompt.
1934        if let Some(reads) = spec.get("reads").and_then(Value::as_array) {
1935            for path in reads.iter().filter_map(Value::as_str) {
1936                if let Some(v) = template::lookup(path, data) {
1937                    messages.push(Msg::system(format!("{path} = {v}")));
1938                }
1939            }
1940        }
1941        // `context`: seed messages — a bare array, or the object form
1942        // `{cards: [...], seed: [...]}` where `cards` controls which
1943        // environment sections THIS step's system prompt carries (node-level
1944        // context control; the config's `context.cards` is the default).
1945        let step_template: Option<String> = spec
1946            .get("context")
1947            .and_then(Value::as_object)
1948            .and_then(|o| o.get("template"))
1949            .and_then(Value::as_str)
1950            .map(str::to_string);
1951        let seed_list = spec.get("context").and_then(Value::as_array).or_else(|| {
1952            spec.get("context")
1953                .and_then(Value::as_object)
1954                .and_then(|o| o.get("seed"))
1955                .and_then(Value::as_array)
1956        });
1957        if let Some(seed) = seed_list {
1958            for m in seed {
1959                match (m["role"].as_str(), m["content"].as_str()) {
1960                    (Some("system"), Some(c)) => messages.push(Msg::system(c)),
1961                    (Some("assistant"), Some(c)) => {
1962                        messages.push(Msg::assistant(Some(c.to_string()), vec![]))
1963                    }
1964                    (_, Some(c)) => messages.push(Msg::user(c, None)),
1965                    _ => {}
1966                }
1967            }
1968        }
1969        let mut user = prompt.clone();
1970        if let Some(c) = spec.get("output_contract").and_then(Value::as_str) {
1971            user.push_str(&format!("\n\nOutput contract:\n{c}"));
1972        }
1973        if let Some(s) = &output_schema {
1974            user.push_str(&format!(
1975                "\n\nReply with ONLY one JSON object matching this JSON Schema:\n{s}"
1976            ));
1977        }
1978        messages.push(Msg::user(user, None));
1979        // Skills for the step.
1980        let skill_bodies: Vec<String> = step
1981            .skills
1982            .iter()
1983            .chain(
1984                spec.get("skills")
1985                    .and_then(Value::as_array)
1986                    .map(|a| {
1987                        a.iter()
1988                            .filter_map(Value::as_str)
1989                            .map(str::to_string)
1990                            .collect::<Vec<_>>()
1991                    })
1992                    .unwrap_or_default()
1993                    .iter(),
1994            )
1995            .filter_map(|name| {
1996                let mcp = self.mcp.clone();
1997                let resolver = move |server: &str| -> Option<
1998                    std::sync::Arc<dyn crate::context::skills::SkillServer>,
1999                > {
2000                    mcp.get(server).map(|c| {
2001                        c.clone() as std::sync::Arc<dyn crate::context::skills::SkillServer>
2002                    })
2003                };
2004                self.skills
2005                    .load(name, None, &resolver)
2006                    .ok()
2007                    .map(|b| format!("### Skill: {}\n{}", b.name, b.body))
2008            })
2009            .collect();
2010        let extra = if skill_bodies.is_empty() {
2011            None
2012        } else {
2013            Some(format!(
2014                "Loaded skills — follow these instructions when relevant:\n{}",
2015                skill_bodies.join("\n\n")
2016            ))
2017        };
2018        let system = match spec.get("system").and_then(Value::as_str) {
2019            Some(s) => s.to_string(),
2020            None if is_think => format!(
2021                "You are the reasoning module of {}. Reply with {}. No tools are available.",
2022                self.instance,
2023                if output_schema.is_some() {
2024                    "ONLY one JSON object matching the schema"
2025                } else {
2026                    "your conclusion"
2027                }
2028            ),
2029            None => self.system_prompt_named(None, extra.as_deref(), step_template.as_deref()),
2030        };
2031        let (tools, internal, routes) = if is_think {
2032            (Vec::new(), Vec::new(), BTreeMap::new())
2033        } else {
2034            let allow: Option<Vec<String>> = spec.get("tools").and_then(Value::as_array).map(|a| {
2035                a.iter()
2036                    .filter_map(Value::as_str)
2037                    .map(str::to_string)
2038                    .collect()
2039            });
2040            self.tool_plan(&Caller::Workflow, allow.as_deref())
2041        };
2042        let servers: Vec<String> = match spec.get("servers").and_then(Value::as_array) {
2043            Some(a) => a
2044                .iter()
2045                .filter_map(Value::as_str)
2046                .map(str::to_string)
2047                .collect(),
2048            None => routes
2049                .values()
2050                .map(|(s, _)| s.clone())
2051                .collect::<std::collections::BTreeSet<_>>()
2052                .into_iter()
2053                .collect(),
2054        };
2055        // Budget admission: the estimate is charged against every scope this
2056        // run belongs to as well as the instance, so the tightest one binds.
2057        let est: u64 = messages.iter().map(Msg::est_tokens).sum::<u64>()
2058            + crate::context::tokens::estimate(&system)
2059            + 4096;
2060        let scopes = self.run_scopes(run_id);
2061        let reservation = match self.governor.admit(est, &scopes, now_ms()) {
2062            Admission::Ok { reservation, model } => {
2063                if let Some(m) = model {
2064                    self.log.info(
2065                        "budget.degraded",
2066                        json!({"run": run_id, "step": step_id, "model": m}),
2067                    );
2068                }
2069                Some(reservation)
2070            }
2071            Admission::Wait { until_ms, reason } => {
2072                self.log.info(
2073                    "budget.wait",
2074                    json!({"run": run_id, "step": step_id, "until_ms": until_ms, "reason": reason}),
2075                );
2076                crate::state::kill_point("budget.waiting");
2077                match self.timers.arm(
2078                    &self.durable,
2079                    until_ms,
2080                    json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2081                    Value::Null,
2082                ) {
2083                    Ok(id) => {
2084                        self.runs.get_mut(run_id).expect("present").suspend_step(step_id, json!({"kind": "waiting_budget", "timer": id, "until_ms": until_ms, "reason": reason}));
2085                        self.checkpoint(false);
2086                    }
2087                    Err(e) => self.finish_step(
2088                        run_id,
2089                        step_id,
2090                        StepStatus::Failed,
2091                        None,
2092                        Some(format!("budget wait: {e}")),
2093                        0,
2094                    ),
2095                }
2096                return;
2097            }
2098            Admission::Refuse { reason } | Admission::Fail { reason } => {
2099                self.finish_step(run_id, step_id, StepStatus::Failed, None, Some(reason), 0);
2100                return;
2101            }
2102        };
2103        // `model:` on an `agent`/`think` node: cost tiering inside one
2104        // workflow, without forking a subagent process just to change a model
2105        // string. Read here, because `spec` is shadowed by the `TurnSpec`
2106        // below.
2107        let node_model = spec
2108            .get("model")
2109            .and_then(Value::as_str)
2110            .map(str::to_string);
2111        let limits = spec.get("limits").cloned().unwrap_or(json!({}));
2112        let max_steps = limits
2113            .get("steps")
2114            .and_then(Value::as_u64)
2115            .map(|s| s as u32)
2116            .unwrap_or(self.settings.limits.run.steps());
2117        let max_tokens = step
2118            .budget
2119            .or_else(|| limits.get("tokens").and_then(Value::as_u64))
2120            .unwrap_or(self.settings.limits.run.tokens());
2121        let deadline_ms = step.timeout_ms.unwrap_or(
2122            self.settings
2123                .limits
2124                .step_timeout
2125                .map(|d| d.0.as_millis() as u64)
2126                .unwrap_or(600_000),
2127        );
2128        let spec = TurnSpec {
2129            kind: if is_think {
2130                TurnKind::Think
2131            } else {
2132                TurnKind::Agent
2133            },
2134            system,
2135            messages,
2136            tools,
2137            internal,
2138            mcp_routes: routes,
2139            output_schema,
2140            max_rounds: if is_think { 3 } else { 0 },
2141            budget_admission: self.governor.is_active(),
2142            idempotency_prefix: format!("{}/{run_id}/{step_id}", self.instance),
2143            tool_meta: Some(
2144                json!({"agent/run": run_id, "agent/step": step_id, "agent/instance": self.instance}),
2145            ),
2146            temperature: None,
2147            max_tokens_per_call: 0,
2148            turn_id: format!(
2149                "{run_id}/{step_id}#{}",
2150                self.runs
2151                    .get(run_id)
2152                    .and_then(|r| r.step(step_id))
2153                    .map(|s| s.attempt)
2154                    .unwrap_or(1)
2155            ),
2156        };
2157        let launch = super::turns::TurnLaunch {
2158            spec,
2159            kind: ChildKind::StepTurn {
2160                run: run_id.to_string(),
2161                step: step_id.to_string(),
2162                reservation,
2163            },
2164            servers,
2165            max_steps,
2166            max_tokens,
2167            deadline_ms,
2168            agent_path: format!("run/{run_id}/{step_id}"),
2169            model: node_model,
2170        };
2171        match self.spawn_turn(launch) {
2172            Ok(node) => {
2173                if let Some(st) = self
2174                    .runs
2175                    .get_mut(run_id)
2176                    .and_then(|r| r.steps.get_mut(step_id))
2177                {
2178                    st.worker = Some(node.0.to_string());
2179                }
2180                self.log.info(
2181                    "step.turn.spawn",
2182                    json!({"run": run_id, "step": step_id, "node": node.0}),
2183                );
2184            }
2185            Err(e) => {
2186                if let Some(r) = reservation {
2187                    self.governor.release(r);
2188                }
2189                self.finish_step(
2190                    run_id,
2191                    step_id,
2192                    StepStatus::Failed,
2193                    None,
2194                    Some(format!("spawn: {e}")),
2195                    0,
2196                );
2197            }
2198        }
2199    }
2200
2201    /// The governor scopes a run's turns are charged to (workflow budget → run scope).
2202    fn run_scopes(&mut self, run_id: &str) -> Vec<String> {
2203        // A run's model spend is charged to the principal it is being done for
2204        // as well as to the run itself, so a per-person ceiling covers the
2205        // work someone STARTED, not only the turns they typed.
2206        let mut scopes = self.principal_scopes(
2207            self.runs
2208                .get(run_id)
2209                .and_then(|r| r.principal.clone())
2210                .as_deref(),
2211        );
2212        let Some(wf) = self.definition_for_run(run_id) else {
2213            return scopes;
2214        };
2215        if let Some(b) = wf
2216            .limits
2217            .budget
2218            .as_ref()
2219            .and_then(|b| serde_json::from_value::<crate::config::v2::Budget>(b.clone()).ok())
2220        {
2221            let key = format!("run:{run_id}");
2222            self.governor.ensure_scope(&key, &b);
2223            scopes.push(key);
2224        }
2225        scopes
2226    }
2227
2228    // ---- outcomes --------------------------------------------------------------
2229
2230    /// An executor / deferred tool finished a step.
2231    pub(crate) fn on_step_done(
2232        &mut self,
2233        run_id: &str,
2234        step_id: &str,
2235        output: Value,
2236        is_error: bool,
2237        error: Option<String>,
2238        tokens: u64,
2239    ) {
2240        self.executing.remove(&format!("{run_id}/{step_id}"));
2241        self.finish_step(
2242            run_id,
2243            step_id,
2244            if is_error {
2245                StepStatus::Failed
2246            } else {
2247                StepStatus::Done
2248            },
2249            Some(output),
2250            error,
2251            tokens,
2252        );
2253    }
2254
2255    /// A step's turn worker finished.
2256    pub(crate) fn on_step_turn_done(&mut self, run_id: &str, step_id: &str, turn: TurnResult) {
2257        let tokens = turn.usage.total();
2258        if turn.status == "completed" {
2259            let output = turn
2260                .value
2261                .clone()
2262                .or_else(|| turn.finish.as_ref().and_then(|f| f.get("output").cloned()))
2263                .or_else(|| turn.text.clone().map(Value::String))
2264                .unwrap_or(Value::Null);
2265            // A `finish {status: failed|refused}` from an agent step fails the step.
2266            let failed = turn
2267                .finish
2268                .as_ref()
2269                .and_then(|f| f.get("status"))
2270                .and_then(Value::as_str)
2271                .is_some_and(|s| s != "completed");
2272            if failed {
2273                let reason = turn
2274                    .finish
2275                    .as_ref()
2276                    .and_then(|f| f.get("reason"))
2277                    .and_then(Value::as_str)
2278                    .unwrap_or("agent finished with a non-completed status")
2279                    .to_string();
2280                self.finish_step(
2281                    run_id,
2282                    step_id,
2283                    StepStatus::Failed,
2284                    Some(output),
2285                    Some(reason),
2286                    tokens,
2287                );
2288            } else {
2289                self.finish_step(
2290                    run_id,
2291                    step_id,
2292                    StepStatus::Done,
2293                    Some(output),
2294                    None,
2295                    tokens,
2296                );
2297            }
2298        } else {
2299            let status = if turn.status == "deadline" {
2300                StepStatus::Timeout
2301            } else {
2302                StepStatus::Failed
2303            };
2304            self.finish_step(
2305                run_id,
2306                step_id,
2307                status,
2308                turn.value
2309                    .clone()
2310                    .or_else(|| turn.text.clone().map(Value::String)),
2311                Some(format!(
2312                    "turn {}{}",
2313                    turn.status,
2314                    turn.error
2315                        .as_deref()
2316                        .map(|e| format!(": {e}"))
2317                        .unwrap_or_default()
2318                )),
2319                tokens,
2320            );
2321        }
2322    }
2323
2324    /// A step timer fired (`sleep` done, or a budget window opened).
2325    pub(crate) fn on_step_timer(
2326        &mut self,
2327        run_id: &str,
2328        step_id: &str,
2329        budget: bool,
2330        payload: &Value,
2331    ) {
2332        if budget {
2333            if let Some(st) = self
2334                .runs
2335                .get_mut(run_id)
2336                .and_then(|r| r.steps.get_mut(step_id))
2337            {
2338                st.status = StepStatus::Pending;
2339                st.wait = None;
2340            }
2341            if let Some(r) = self.runs.get_mut(run_id) {
2342                r.touch();
2343            }
2344            return;
2345        }
2346        self.finish_step(
2347            run_id,
2348            step_id,
2349            StepStatus::Done,
2350            Some(payload.clone()),
2351            None,
2352            0,
2353        );
2354    }
2355
2356    /// Record a step's terminal outcome; retry / route failures; checkpoint.
2357    pub(crate) fn finish_step_pub(
2358        &mut self,
2359        run_id: &str,
2360        step_id: &str,
2361        status: StepStatus,
2362        output: Option<Value>,
2363        error: Option<String>,
2364        tokens: u64,
2365    ) {
2366        self.finish_step(run_id, step_id, status, output, error, tokens)
2367    }
2368
2369    fn finish_step(
2370        &mut self,
2371        run_id: &str,
2372        step_id: &str,
2373        status: StepStatus,
2374        output: Option<Value>,
2375        error: Option<String>,
2376        tokens: u64,
2377    ) {
2378        // A terminal step may make dependents ready this very iteration — tell
2379        // the loop to re-run scheduling before it parks (the inline fixpoint).
2380        self.resched = true;
2381        let Some(wf) = self
2382            .runs
2383            .get(run_id)
2384            .and_then(|_| self.definition_for_run(run_id))
2385        else {
2386            return;
2387        };
2388        let Some((step, scope)) = self
2389            .runs
2390            .get(run_id)
2391            .and_then(|r| self.resolve_step(&wf, r, step_id))
2392        else {
2393            return;
2394        };
2395        {
2396            let run = self.runs.get_mut(run_id).expect("present");
2397            if run.status.is_terminal() {
2398                return; // a late result for a finished run
2399            }
2400            run.tokens += tokens;
2401        }
2402        crate::obs::metrics::record_step(match status {
2403            StepStatus::Done => "done",
2404            StepStatus::Failed => "failed",
2405            _ => "other",
2406        });
2407        // An output past `limits.inline_max_bytes` is stored as an artifact and
2408        // replaced by a reference, keeping the run record (and every checkpoint
2409        // that serializes it) bounded. A failed artifact write keeps the inline
2410        // value rather than losing the output.
2411        let output = match output {
2412            Some(v) if !v.is_null() => {
2413                let cap = self.settings.limits.inline_max_bytes.unwrap_or(65_536) as usize;
2414                if v.to_string().len() > cap {
2415                    match self.artifacts.create(
2416                        &self.durable,
2417                        super::artifacts::NewArtifact {
2418                            name: &format!("{run_id}/{step_id}/output.json"),
2419                            mime: Some("application/json"),
2420                            content: v.clone(),
2421                            created_by: Some("engine"),
2422                            sensitive: false,
2423                            owner: Some(run_id),
2424                        },
2425                    ) {
2426                        Ok(meta) => {
2427                            self.log.info("step.output.artifact", json!({"run": run_id, "step": step_id, "artifact": meta["id"], "size": meta["size"]}));
2428                            Some(json!({"$artifact": meta["id"], "size": meta["size"]}))
2429                        }
2430                        Err(e) => {
2431                            self.log.warn(
2432                                "step.output.artifact_fail",
2433                                json!({"run": run_id, "step": step_id, "err": e}),
2434                            );
2435                            Some(v)
2436                        }
2437                    }
2438                } else {
2439                    Some(v)
2440                }
2441            }
2442            other => other,
2443        };
2444        // A declared `output_schema` is enforced here: a step that completed
2445        // but produced a shape its consumers cannot parse fails instead.
2446        let (status, error) = match (&status, &step.output_schema, &output) {
2447            (StepStatus::Done, Some(schema), Some(out)) => {
2448                match crate::jsonschema::validate(schema, out) {
2449                    Ok(()) => (status, error),
2450                    Err(e) => (
2451                        StepStatus::Failed,
2452                        Some(format!(
2453                            "output does not match output_schema: {}",
2454                            crate::jsonschema::explain(&e)
2455                        )),
2456                    ),
2457                }
2458            }
2459            _ => (status, error),
2460        };
2461        let attempt = self
2462            .runs
2463            .get(run_id)
2464            .and_then(|r| r.step(step_id))
2465            .map(|s| s.attempt)
2466            .unwrap_or(1);
2467        self.log.info("step.done", json!({"run": run_id, "step": step_id, "status": status, "attempt": attempt, "tokens": tokens, "err": error}));
2468        // Feed the circuit breaker, when this step keeps one. Every ATTEMPT
2469        // counts (a breaker measures calls, not runs) — except our own
2470        // fast-fails: refusing to dial is not evidence about the remote.
2471        if matches!(
2472            step.kind.as_str(),
2473            "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
2474        ) && let Some(cfg) = self.effective_breaker(&step)
2475            && !matches!(status, StepStatus::Failed | StepStatus::Timeout if error
2476                .as_deref()
2477                .is_some_and(|e| e.starts_with(super::breaker::OPEN_ERR)))
2478            && matches!(
2479                status,
2480                StepStatus::Done | StepStatus::Failed | StepStatus::Timeout
2481            )
2482        {
2483            let workflow = self
2484                .runs
2485                .get(run_id)
2486                .map(|r| r.workflow.clone())
2487                .unwrap_or_default();
2488            let key = super::breaker::key(&workflow, step_id);
2489            let mut st = self
2490                .durable
2491                .manifest()
2492                .breakers
2493                .get(&key)
2494                .cloned()
2495                .unwrap_or_else(|| json!({}));
2496            let t = super::breaker::record(&mut st, cfg, status == StepStatus::Done, now_ms());
2497            self.durable.manifest_update(|m| {
2498                m.breakers.insert(key.clone(), st);
2499            });
2500            match t {
2501                super::breaker::Transition::Opened { fails } => self.log.warn(
2502                    "breaker.open",
2503                    json!({"breaker": key, "consecutive_failures": fails,
2504                           "cooldown": format!("{}ms", cfg.cooldown_ms)}),
2505                ),
2506                super::breaker::Transition::Reopened => self.log.warn(
2507                    "breaker.reopen",
2508                    json!({"breaker": key, "probe_failed": true}),
2509                ),
2510                super::breaker::Transition::Closed => {
2511                    self.log.info("breaker.closed", json!({"breaker": key}))
2512                }
2513                super::breaker::Transition::None => {}
2514            }
2515        }
2516        #[cfg(feature = "a2a")]
2517        self.feed_push(
2518            "step",
2519            crate::runtime::a2a_server::FeedVis::Operator,
2520            json!({"run": run_id, "step": step_id, "phase": "done",
2521                   "status": crate::runtime::nested::StatusLabel::as_label(&status), "attempt": attempt, "tokens": tokens,
2522                   // Same 2 KiB cap the run drill-down applies: a step output can
2523                   // be an entire document, and a feed is not the place for it.
2524                   "err": error.as_deref().map(|e| {
2525                       e.chars().take(2048).collect::<String>()
2526                   })}),
2527        );
2528        if matches!(status, StepStatus::Failed | StepStatus::Timeout) {
2529            // Retry?
2530            if let Some(retry) = &step.retry
2531                && attempt <= retry.max
2532            {
2533                // Exponential, with jitter. Without it every step that failed
2534                // in the same wave — the usual case, since they usually failed
2535                // for the same upstream reason — retries in lockstep and
2536                // rebuilds the thundering herd the backoff exists to break up.
2537                // Deterministic per (run, step, attempt): no RNG, so a replay
2538                // reproduces the same schedule.
2539                let base = retry
2540                    .backoff_ms
2541                    .saturating_mul(1u64 << (attempt.saturating_sub(1)).min(10));
2542                let backoff = if base == 0 {
2543                    0
2544                } else {
2545                    let mut h: u64 = 1469598103934665603;
2546                    for b in run_id.bytes().chain(step_id.bytes()).chain([attempt as u8]) {
2547                        h ^= b as u64;
2548                        h = h.wrapping_mul(1099511628211);
2549                    }
2550                    // ±20% around the base.
2551                    let spread = (base / 5).max(1);
2552                    base.saturating_sub(spread) + (h % (spread * 2 + 1))
2553                };
2554                self.log.info("step.retry", json!({"run": run_id, "step": step_id, "attempt": attempt, "backoff_ms": backoff}));
2555                if backoff == 0 {
2556                    if let Some(st) = self
2557                        .runs
2558                        .get_mut(run_id)
2559                        .and_then(|r| r.steps.get_mut(step_id))
2560                    {
2561                        st.status = StepStatus::Pending;
2562                        st.error = error;
2563                    }
2564                } else {
2565                    match self.timers.arm(
2566                        &self.durable,
2567                        now_ms() + backoff,
2568                        json!({"kind": "step_budget", "run": run_id, "step": step_id}),
2569                        Value::Null,
2570                    ) {
2571                        Ok(id) => {
2572                            self.runs.get_mut(run_id).expect("present").suspend_step(
2573                                step_id,
2574                                json!({"kind": "retry_backoff", "timer": id, "error": error}),
2575                            );
2576                        }
2577                        Err(_) => {
2578                            if let Some(st) = self
2579                                .runs
2580                                .get_mut(run_id)
2581                                .and_then(|r| r.steps.get_mut(step_id))
2582                            {
2583                                st.status = StepStatus::Pending;
2584                            }
2585                        }
2586                    }
2587                }
2588                self.checkpoint(false);
2589                return;
2590            }
2591            let err_text = error.clone().unwrap_or_else(|| "failed".into());
2592            self.runs
2593                .get_mut(run_id)
2594                .expect("present")
2595                .end_step(step_id, status, output, error);
2596            // `on_timeout`: a deadline expiring on a wait is usually an
2597            // EXPECTED branch (nobody replied, the alert never cleared), not
2598            // a failure — route to the named step, forced, and keep the run
2599            // alive. Only a real Timeout takes this edge; other errors still
2600            // answer to `on_error`. The step itself stays `Timeout`, which
2601            // does NOT satisfy dependents — so the success path and the
2602            // timeout path are mutually exclusive by construction.
2603            if status == StepStatus::Timeout
2604                && let Some(t) = step.field_str("on_timeout")
2605            {
2606                let target = match &scope {
2607                    Some(sc) => super::nested::scoped_id(&sc.parent, t),
2608                    None => t.to_string(),
2609                };
2610                if let Some(st) = self
2611                    .runs
2612                    .get_mut(run_id)
2613                    .and_then(|r| r.steps.get_mut(&target))
2614                {
2615                    st.status = StepStatus::Pending;
2616                    st.forced = true;
2617                }
2618                self.log.info(
2619                    "step.timeout_routed",
2620                    json!({"run": run_id, "step": step_id, "to": t}),
2621                );
2622                crate::state::kill_point("step.before_done");
2623                self.checkpoint(false);
2624                if scope.is_some() {
2625                    self.on_scoped_step_done(run_id, step_id);
2626                }
2627                return;
2628            }
2629            if let Some(sc) = &scope {
2630                // Inside a body: `continue` marks done-with-error, `goto` re-arms
2631                // a sibling, `fail` leaves the step failed for the parent to judge.
2632                match &step.on_error {
2633                    OnError::Continue => {
2634                        if let Some(st) = self
2635                            .runs
2636                            .get_mut(run_id)
2637                            .and_then(|r| r.steps.get_mut(step_id))
2638                        {
2639                            st.status = StepStatus::Done;
2640                            st.error = Some(err_text.clone());
2641                            if st.output.is_none() {
2642                                st.output = Some(json!({"error": err_text}));
2643                            }
2644                        }
2645                    }
2646                    OnError::Goto(t) => {
2647                        let sid = super::nested::scoped_id(&sc.parent, t);
2648                        if let Some(st) = self
2649                            .runs
2650                            .get_mut(run_id)
2651                            .and_then(|r| r.steps.get_mut(&sid))
2652                        {
2653                            st.status = StepStatus::Pending;
2654                            st.forced = true;
2655                        }
2656                    }
2657                    OnError::Fail => {}
2658                }
2659                crate::state::kill_point("step.before_done");
2660                if !crate::engine::model::pure_data_kind(&step.kind) {
2661                    self.checkpoint(false);
2662                }
2663                self.on_scoped_step_done(run_id, step_id);
2664                return;
2665            }
2666            let routed = run::route_failure(
2667                &wf,
2668                self.runs.get_mut(run_id).expect("present"),
2669                &step,
2670                &err_text,
2671            );
2672            match routed {
2673                Ok(next) => {
2674                    if !next.is_empty() {
2675                        self.log.info(
2676                            "step.goto",
2677                            json!({"run": run_id, "from": step_id, "to": next}),
2678                        );
2679                    }
2680                }
2681                Err(reason) => {
2682                    self.cancel_children_of_run(run_id, "run failed");
2683                    self.runs.get_mut(run_id).expect("present").finish(
2684                        RunStatus::Failed,
2685                        None,
2686                        Some(reason),
2687                    );
2688                    self.on_run_terminal(run_id);
2689                    return;
2690                }
2691            }
2692            if let OnError::Continue = step.on_error {
2693                // Already marked done-with-error by route_failure.
2694            }
2695        } else {
2696            // Memoize (`cache`).
2697            if step.cache.is_some()
2698                && status == StepStatus::Done
2699                && let Some(key) = self
2700                    .runs
2701                    .get(run_id)
2702                    .and_then(|r| r.steps.get(step_id))
2703                    .and_then(|st| st.cache_key.clone())
2704                && let Some(out) = &output
2705            {
2706                self.cache_store(&key, out);
2707            }
2708            self.runs
2709                .get_mut(run_id)
2710                .expect("present")
2711                .end_step(step_id, status, output, error);
2712        }
2713        crate::state::kill_point("step.before_done");
2714        // Pure steps ride the tick's checkpoint — unless this completion made
2715        // the RUN terminal (a routed failure), which must land durably now.
2716        let terminal_now = self
2717            .runs
2718            .get(run_id)
2719            .is_some_and(|r| r.status.is_terminal());
2720        if terminal_now || !crate::engine::model::pure_data_kind(&step.kind) {
2721            self.checkpoint(false);
2722        }
2723        if scope.is_some() {
2724            self.on_scoped_step_done(run_id, step_id);
2725        }
2726    }
2727
2728    /// The run reached a terminal state: report, wake, plan bindings, counters.
2729    /// Evict terminal runs beyond `store.retention.runs`.
2730    ///
2731    /// Without this a long-lived instance keeps one durable record per run
2732    /// forever — on a laptop, the difference between an agent that runs for a
2733    /// month and one that fills a disk. Only TERMINAL runs are candidates:
2734    /// nothing in flight is ever dropped, whatever the policy says. Default is
2735    /// unbounded, so an operator who has not thought about it keeps today's
2736    /// behaviour.
2737    fn evict_terminal_runs(&mut self) {
2738        let policy = &self.settings.store.retention.runs;
2739        let keep_last = policy.keep_last;
2740        let ttl_ms = policy.ttl.as_ref().map(|d| d.0.as_millis() as u64);
2741        if keep_last.is_none() && ttl_ms.is_none() {
2742            return;
2743        }
2744        let now = now_ms();
2745        // Newest first, so "keep the last N" is a prefix.
2746        let mut terminal: Vec<(String, u64)> = self
2747            .runs
2748            .values()
2749            .filter(|r| r.status.is_terminal())
2750            .map(|r| (r.id.clone(), r.finished.unwrap_or(0)))
2751            .collect();
2752        terminal.sort_by_key(|(_, finished)| std::cmp::Reverse(*finished));
2753
2754        let mut drop: Vec<String> = Vec::new();
2755        for (i, (id, finished)) in terminal.iter().enumerate() {
2756            let over_count = keep_last.is_some_and(|k| i >= k as usize);
2757            let over_age = ttl_ms.is_some_and(|t| now.saturating_sub(*finished) > t);
2758            if over_count || over_age {
2759                drop.push(id.clone());
2760            }
2761        }
2762        for id in drop {
2763            // A non-durable run has nothing in the store to evict.
2764            let was_durable = self.runs.get(&id).is_none_or(|r| r.durable);
2765            self.runs.remove(&id);
2766            if was_durable && let Err(e) = self.durable.delete(crate::state::Kind::Run, &id) {
2767                self.log
2768                    .warn("run.evict.fail", json!({"run": id, "err": e.to_string()}));
2769                continue;
2770            }
2771            self.log.info("run.evicted", json!({"run": id}));
2772        }
2773    }
2774
2775    pub(crate) fn on_run_terminal(&mut self, run_id: &str) {
2776        let Some(run) = self.runs.get(run_id) else {
2777            return;
2778        };
2779        let (status, output, error, workflow) = (
2780            run.status,
2781            run.output.clone(),
2782            run.error.clone(),
2783            run.workflow.clone(),
2784        );
2785        #[cfg(feature = "a2a")]
2786        let a2a_task = run.task.clone();
2787        // Eviction runs here because this is the only moment the candidate set
2788        // grows. Deferred to the end of the function so the run's own
2789        // completion handling (webhook reply, A2A task, feed) happens first —
2790        // evicting a record before its result was delivered would be a fine way
2791        // to lose an answer.
2792        let evict_after = true;
2793        // A `respond: sync` webhook awaiting this run gets its result now.
2794        #[cfg(feature = "a2a")]
2795        self.webhook_sync_reply(run_id);
2796        // A queued child run (`child_run` wait) resolves its parent step.
2797        if let Some(parent) = self.runs.get(run_id).and_then(|r| r.parent.clone())
2798            && let (Some(pr), Some(ps)) = (
2799                parent["run"].as_str().map(str::to_string),
2800                parent["step"].as_str().map(str::to_string),
2801            )
2802            && self
2803                .runs
2804                .get(&pr)
2805                .and_then(|r| r.steps.get(&ps))
2806                .is_some_and(|st| {
2807                    st.status == StepStatus::Suspended
2808                        && st.wait.as_ref().is_some_and(|w| w["kind"] == "child_run")
2809                })
2810        {
2811            self.finish_step_pub(
2812                &pr,
2813                &ps,
2814                if status == RunStatus::Completed {
2815                    StepStatus::Done
2816                } else {
2817                    StepStatus::Failed
2818                },
2819                Some(json!({"run": run_id, "status": status, "output": output, "error": error})),
2820                (status != RunStatus::Completed).then(|| {
2821                    error
2822                        .clone()
2823                        .unwrap_or_else(|| format!("child run {}", status.as_str()))
2824                }),
2825                0,
2826            );
2827        }
2828        self.counters.runs_finished += 1;
2829        crate::obs::metrics::record_run(match status {
2830            RunStatus::Completed => crate::obs::metrics::RunOutcome::Completed,
2831            RunStatus::Cancelled => crate::obs::metrics::RunOutcome::Killed,
2832            _ => crate::obs::metrics::RunOutcome::Failed,
2833        });
2834        crate::obs::metrics::record_run_status(status.as_str());
2835        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 }}));
2836        self.governor.drop_scope(&format!("run:{run_id}"));
2837        self.retire_sweep();
2838        // Durable-pin GC for the ordinary path: when the LAST run of a
2839        // definition version lands, its stored pin has no reader left. (A
2840        // still-armed workflow re-pins on its next run's first start.)
2841        if let Some(hash) = self.runs.get(run_id).map(|r| r.workflow_hash.clone())
2842            && !self
2843                .runs
2844                .values()
2845                .any(|r| !r.status.is_terminal() && r.workflow_hash == hash)
2846        {
2847            let _ = self.durable.delete(
2848                crate::state::Kind::Memory,
2849                &format!("{}{hash}", super::retire::PIN_PREFIX),
2850            );
2851            self.pin_written.remove(&hash);
2852        }
2853        // Answer waiters (workflow.wait / run sync).
2854        let waiting: Vec<Target> = self
2855            .pending
2856            .iter()
2857            .filter(|p| matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id))
2858            .map(|p| p.target.clone())
2859            .collect();
2860        self.pending
2861            .retain(|p| !matches!(&p.kind, PendingKind::Run { run, .. } if run == run_id));
2862        for t in waiting {
2863            self.reply(
2864                &t,
2865                json!({"run": run_id, "status": status, "output": output, "error": error}),
2866                false,
2867            );
2868        }
2869        // Plan bindings + the root note (wake policy).
2870        let ok = status == RunStatus::Completed;
2871        let note = error
2872            .clone()
2873            .or_else(|| output.as_ref().map(|o| o.to_string()))
2874            .unwrap_or_default();
2875        self.settle_plan_bindings(
2876            &crate::context::plan::Binding::Run {
2877                id: run_id.to_string(),
2878            },
2879            ok,
2880            &note,
2881        );
2882        let wake = self.settings.agent.wake_on();
2883        let notify = match self.settings.agent.on_workflow_finished {
2884            crate::config::v2::OnWorkflowFinished::Ignore => false,
2885            _ => {
2886                ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFinished)
2887                    || !ok && wake.contains(&crate::config::v2::WakeEvent::WorkflowFailed)
2888            }
2889        };
2890        if notify && !self.job_shape {
2891            let short = if note.chars().count() > 400 {
2892                format!("{}…", note.chars().take(400).collect::<String>())
2893            } else {
2894                note.clone()
2895            };
2896            let line = format!(
2897                "workflow {workflow} run {run_id} {}: {short}",
2898                status.as_str()
2899            );
2900            match self.settings.agent.on_workflow_finished {
2901                // `note` appends to the root transcript and waits for whatever
2902                // happens next to read it. `think` delivers, which starts a
2903                // turn: the difference between leaving a message and making
2904                // the call. The hop depth continues this run's chain, so a
2905                // workflow the agent started cannot wake it without bound.
2906                crate::config::v2::OnWorkflowFinished::Think => {
2907                    let depth = self.runs.get(run_id).map(|r| r.msg_depth).unwrap_or(0) + 1;
2908                    let cap = self.settings.limits.message_depth();
2909                    if depth > cap {
2910                        self.log.warn(
2911                            "message.too_deep",
2912                            json!({"run": run_id, "reason": "on_workflow_finished",
2913                                   "depth": depth, "max": cap}),
2914                        );
2915                        self.note_root(line);
2916                    } else {
2917                        let principal = self.runs.get(run_id).and_then(|r| r.principal.clone());
2918                        if let Err(e) = self.accept_event(
2919                            kinds::A2A_MESSAGE,
2920                            principal,
2921                            json!({"text": line.clone(), "context_id": crate::context::ROOT,
2922                                   "msg_depth": depth}),
2923                        ) {
2924                            self.log
2925                                .warn("workflow.think.fail", json!({"run": run_id, "err": e}));
2926                            self.note_root(line);
2927                        }
2928                    }
2929                }
2930                _ => self.note_root(line),
2931            }
2932        }
2933        // A `loop` start re-arms the next iteration; `event` start nodes fire on
2934        // workflow.finished/failed.
2935        if let Some((wf, node, spec, kind)) = self.run_start_spec(run_id)
2936            && kind == "loop"
2937        {
2938            self.on_loop_run_finished(
2939                &wf,
2940                &node,
2941                &spec,
2942                ok,
2943                &output.clone().unwrap_or(Value::Null),
2944            );
2945        }
2946        if let Some(ev) = super::starts::run_event(status) {
2947            self.fire_event_starts(
2948                ev,
2949                &json!({"run": run_id, "workflow": workflow, "status": status.as_str()}),
2950            );
2951        }
2952        // A run started over A2A drives its task to the run's outcome.
2953        #[cfg(feature = "a2a")]
2954        if let Some(tid) = &a2a_task {
2955            self.a2a_task_for_run(tid, status.as_str(), output.as_ref(), error.as_deref());
2956        }
2957        self.checkpoint(false);
2958        if evict_after {
2959            self.evict_terminal_runs();
2960        }
2961    }
2962
2963    /// Cancel a run: cancel its children, fail suspended waits, mark cancelled.
2964    pub(crate) fn cancel_run(&mut self, run_id: &str, reason: &str) {
2965        // Cascade to child runs started with `cascade: true` — a cancelled
2966        // parent must not leave its children running unattended.
2967        let kids: Vec<String> = self
2968            .runs
2969            .values()
2970            .filter(|r| {
2971                !r.status.is_terminal()
2972                    && r.parent.as_ref().is_some_and(|p| {
2973                        p["run"].as_str() == Some(run_id) && p["cascade"].as_bool().unwrap_or(true)
2974                    })
2975            })
2976            .map(|r| r.id.clone())
2977            .collect();
2978        for k in kids {
2979            self.cancel_run(&k, "parent run cancelled");
2980        }
2981        self.cancel_children_of_run(run_id, reason);
2982        let timers = self.timers.owned_by(|o| o["run"].as_str() == Some(run_id));
2983        for t in timers {
2984            let _ = self.timers.disarm(&self.durable, &t);
2985        }
2986        self.pending
2987            .retain(|p| !matches!(&p.target, Target::Step(r, _) if r == run_id));
2988        if let Some(r) = self.runs.get_mut(run_id)
2989            && !r.status.is_terminal()
2990        {
2991            r.finish(RunStatus::Cancelled, None, Some(reason.to_string()));
2992            self.on_run_terminal(run_id);
2993        }
2994    }
2995
2996    fn cancel_children_of_run(&mut self, run_id: &str, reason: &str) {
2997        let nodes: Vec<_> = self
2998            .children
2999            .iter()
3000            .filter(|(_, c)| matches!(&c.kind, ChildKind::StepTurn { run, .. } if run == run_id))
3001            .map(|(n, _)| *n)
3002            .collect();
3003        for n in nodes {
3004            self.children.cancel(n, reason);
3005        }
3006    }
3007
3008    // ---- workflow.* tools ------------------------------------------------------
3009
3010    pub(crate) fn workflow_tool(
3011        &mut self,
3012        caller: &ToolCaller,
3013        name: &str,
3014        args: Value,
3015    ) -> ToolOutcome {
3016        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
3017        match name {
3018            "workflow.run" => {
3019                let wname = args["name"].as_str().unwrap_or("").to_string();
3020                let Some(w) = self.workflows.get(&wname) else {
3021                    return err(format!("no such workflow {wname:?}"));
3022                };
3023                if let Some(cause) = self
3024                    .pressure
3025                    .refusal(w.priority == crate::engine::model::Priority::Low)
3026                {
3027                    return err(format!(
3028                        "workflow.run refused: {cause}; retry when it clears"
3029                    ));
3030                }
3031                let start = match args.get("start").and_then(Value::as_str) {
3032                    Some(s) => match w.step(s) {
3033                        Some(st) if st.is_start() => s.to_string(),
3034                        _ => return err(format!("workflow {wname:?} has no start node {s:?}")),
3035                    },
3036                    None => match default_start(w) {
3037                        Some(s) => s,
3038                        None => return err(format!("workflow {wname:?} has no start node")),
3039                    },
3040                };
3041                let wait = args.get("wait").and_then(Value::as_bool).unwrap_or(false);
3042                let timeout_ms = args
3043                    .get("timeout")
3044                    .and_then(Value::as_str)
3045                    .and_then(|t| crate::config::parse_duration(t).ok())
3046                    .map(|d| d.as_millis() as u64)
3047                    .unwrap_or(3_600_000);
3048                let request = match (caller.node, &caller.run, &caller.step) {
3049                    (Some(n), _, _) => {
3050                        json!({"node": n.0, "req": caller.req, "wait": wait, "timeout_ms": timeout_ms})
3051                    }
3052                    (None, Some(r), Some(s)) => {
3053                        json!({"run": r, "step": s, "wait": wait, "timeout_ms": timeout_ms})
3054                    }
3055                    _ => Value::Null,
3056                };
3057                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});
3058                match self.accept_event(kinds::WORKFLOW_RUN, caller.principal.clone(), payload) {
3059                    Ok(_) => {
3060                        // Process it right away so the caller learns the run id.
3061                        if let Some(ev) = self.inbox_queue.pop_back() {
3062                            let done = self.on_start_event(&ev);
3063                            if done {
3064                                self.inbox_done(&ev.id);
3065                            }
3066                        }
3067                        // The reply is (or will be) delivered through the request
3068                        // target: immediately with the run id, or when the run
3069                        // finishes for `wait: true` (registered by `on_start_event`).
3070                        ToolOutcome::Executing
3071                    }
3072                    Err(e) => err(e),
3073                }
3074            }
3075            "workflow.list" => ToolOutcome::Ready(
3076                json!({"workflows": self.workflows.values().map(|w| json!({
3077                    "name": w.name, "description": w.description, "armed": w.armed, "hash": w.hash,
3078                    "starts": w.start_steps().iter().map(|s| json!({"node": s.id, "kind": s.kind})).collect::<Vec<_>>(),
3079                    "runs": self.runs.values().filter(|r| r.workflow == w.name).map(|r| json!({"id": r.id, "status": r.status})).collect::<Vec<_>>(),
3080                })).collect::<Vec<_>>()}),
3081                false,
3082            ),
3083            "workflow.status" => {
3084                let runs: Vec<Value> = match (
3085                    args.get("run").and_then(Value::as_str),
3086                    args.get("name").and_then(Value::as_str),
3087                ) {
3088                    (Some(id), _) => self
3089                        .runs
3090                        .get(id)
3091                        .map(|r| vec![run_detail(r)])
3092                        .unwrap_or_default(),
3093                    (None, Some(n)) => self
3094                        .runs
3095                        .values()
3096                        .filter(|r| r.workflow == n)
3097                        .map(RunState::summary)
3098                        .collect(),
3099                    _ => self.runs.values().map(RunState::summary).collect(),
3100                };
3101                ToolOutcome::Ready(json!({"runs": runs}), false)
3102            }
3103            "workflow.cancel" => {
3104                let id = args["run"].as_str().unwrap_or("").to_string();
3105                if !self.runs.contains_key(&id) {
3106                    return err(format!("no such run {id:?}"));
3107                }
3108                let reason = args
3109                    .get("reason")
3110                    .and_then(Value::as_str)
3111                    .unwrap_or("cancelled by request")
3112                    .to_string();
3113                self.cancel_run(&id, &reason);
3114                ToolOutcome::Ready(
3115                    json!({"ok": true, "status": self.runs.get(&id).map(|r| r.status.as_str()).unwrap_or("cancelled")}),
3116                    false,
3117                )
3118            }
3119            "workflow.wait" => {
3120                let id = args["run"].as_str().unwrap_or("").to_string();
3121                let timeout_ms = args
3122                    .get("timeout")
3123                    .and_then(Value::as_str)
3124                    .and_then(|t| crate::config::parse_duration(t).ok())
3125                    .map(|d| d.as_millis() as u64)
3126                    .unwrap_or(3_600_000);
3127                match self.runs.get(&id) {
3128                    None => err(format!("no such run {id:?}")),
3129                    Some(r) if r.status.is_terminal() => ToolOutcome::Ready(
3130                        json!({"run": id, "status": r.status, "output": r.output, "error": r.error}),
3131                        false,
3132                    ),
3133                    Some(_) => ToolOutcome::Deferred(PendingKind::Run {
3134                        run: id,
3135                        deadline_ms: now_ms() + timeout_ms,
3136                    }),
3137                }
3138            }
3139            "workflow.pause" | "workflow.resume" => {
3140                let pause = name == "workflow.pause";
3141                // `before_step`: pause the run the moment a named step is about
3142                // to start, rather than immediately. This is a breakpoint —
3143                // "stop when you reach `notify`" — which is what you actually
3144                // want when debugging a graph, and it needs no new surface
3145                // because pause already exists and already survives a restart.
3146                if pause
3147                    && let Some(id) = args.get("run").and_then(Value::as_str)
3148                    && let Some(step) = args.get("before_step").and_then(Value::as_str)
3149                {
3150                    let known = self
3151                        .definition_for_run(id)
3152                        .is_some_and(|wf| wf.steps.contains_key(step));
3153                    if !known {
3154                        return err(format!(
3155                            "before_step {step:?} is not a step of this run's workflow"
3156                        ));
3157                    }
3158                    match self.runs.get_mut(id) {
3159                        None => return err(format!("no such run {id:?}")),
3160                        Some(r) => {
3161                            r.break_before = Some(step.to_string());
3162                            r.dirty = true;
3163                        }
3164                    }
3165                    self.log
3166                        .info("run.breakpoint", json!({"run": id, "before_step": step}));
3167                    return ToolOutcome::Ready(json!({"run": id, "break_before": step}), false);
3168                }
3169                if let Some(id) = args.get("run").and_then(Value::as_str) {
3170                    match self.runs.get_mut(id) {
3171                        None => return err(format!("no such run {id:?}")),
3172                        Some(r) if r.status.is_terminal() => {
3173                            return err(format!("run {id:?} is already {}", r.status.as_str()));
3174                        }
3175                        Some(r) => {
3176                            r.status = if pause {
3177                                RunStatus::Paused
3178                            } else {
3179                                RunStatus::Running
3180                            };
3181                            r.touch();
3182                        }
3183                    }
3184                    return ToolOutcome::Ready(json!({"ok": true}), false);
3185                }
3186                if let Some(n) = args.get("name").and_then(Value::as_str) {
3187                    match self.workflows.get_mut(n) {
3188                        None => return err(format!("no such workflow {n:?}")),
3189                        // The definitions are shared (`Arc`) on the hot path;
3190                        // arming is the one mutation, and it is rare —
3191                        // copy-on-write is the honest cost here.
3192                        Some(w) => std::sync::Arc::make_mut(w).armed = !pause,
3193                    }
3194                    if !pause {
3195                        self.arm_workflows();
3196                    }
3197                    return ToolOutcome::Ready(json!({"ok": true}), false);
3198                }
3199                err(format!("{name}: give run or name"))
3200            }
3201            "workflow.create" | "workflow.update" => {
3202                // Workflows are STANDING instructions — what the agent does
3203                // when a schedule fires or a webhook lands, unattended. An
3204                // agent that can rewrite them changes what happens next time,
3205                // and the change outlives the conversation that caused it.
3206                if let Some(e) = self.workflows_locked(name) {
3207                    return e;
3208                }
3209                let def = args["definition"].clone();
3210                match parse_workflow(&def) {
3211                    Err(e) => err(format!("{name}: {}", e.join("; "))),
3212                    Ok(w) if w.tool.is_some() => err(format!(
3213                        "{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 {:?})",
3214                        w.name
3215                    )),
3216                    Ok(mut w) => {
3217                        self.fill_durable_default(&mut w);
3218                        if name == "workflow.create" && self.workflows.contains_key(&w.name) {
3219                            return err(format!(
3220                                "workflow {:?} exists (use workflow.update)",
3221                                w.name
3222                            ));
3223                        }
3224                        if name == "workflow.update" && !self.workflows.contains_key(&w.name) {
3225                            return err(format!(
3226                                "workflow {:?} does not exist (use workflow.create)",
3227                                w.name
3228                            ));
3229                        }
3230                        let (wname, hash) = (w.name.clone(), w.hash.clone());
3231                        // Durable definition (memory/_workflows/<name>).
3232                        let rec = crate::context::memory::Record {
3233                            value: def,
3234                            ts: now_ms(),
3235                            ttl_ms: None,
3236                            by: Some(caller.label_pub()),
3237                        };
3238                        if let Err(e) = self.durable.put(
3239                            Kind::Memory,
3240                            &format!("{WORKFLOW_DEF_PREFIX}{wname}"),
3241                            serde_json::to_value(&rec).unwrap_or(Value::Null),
3242                            None,
3243                        ) {
3244                            return err(format!("{name}: store: {e}"));
3245                        }
3246                        let arm = args.get("arm").and_then(Value::as_bool).unwrap_or(true);
3247                        let mut w = w;
3248                        w.armed = arm;
3249                        self.workflows.insert(wname.clone(), std::sync::Arc::new(w));
3250                        self.log.info(
3251                            "workflow.defined",
3252                            json!({"name": wname, "hash": &hash[..12], "op": name}),
3253                        );
3254                        if arm {
3255                            self.arm_workflows();
3256                        }
3257                        ToolOutcome::Ready(
3258                            json!({"name": wname, "hash": hash, "armed": arm}),
3259                            false,
3260                        )
3261                    }
3262                }
3263            }
3264            "workflow.delete" => {
3265                if let Some(e) = self.workflows_locked(name) {
3266                    return e;
3267                }
3268                let wname = args["name"].as_str().unwrap_or("").to_string();
3269                let Some(wf) = self.workflows.remove(&wname) else {
3270                    return err(format!("no such workflow {wname:?}"));
3271                };
3272                let _ = self
3273                    .durable
3274                    .delete(Kind::Memory, &format!("{WORKFLOW_DEF_PREFIX}{wname}"));
3275                // Retire rather than drop: retirement pins the definition so
3276                // live runs keep resolving `definition_for_run` mid-flight, and
3277                // applies the workflow's `unload:` policy to them. Delete means
3278                // "stop being a workflow", not "strand whatever is in flight".
3279                self.retire_workflow(&wf, "deleted");
3280                self.log.info("workflow.deleted", json!({"name": wname}));
3281                ToolOutcome::Ready(json!({"ok": true}), false)
3282            }
3283            "workflow.signal" => {
3284                let sname = args["name"].as_str().unwrap_or("").to_string();
3285                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()}));
3286                // The signal goes on the durable inbox; waits and `signal`
3287                // start nodes are woken when the loop drains it, so no
3288                // delivery count is available at this point.
3289                ToolOutcome::Ready(
3290                    json!({"delivered": 0, "note": "signal recorded; waits and signal start nodes are woken when the loop drains the inbox"}),
3291                    false,
3292                )
3293            }
3294            _ => err(format!("unknown workflow tool {name}")),
3295        }
3296    }
3297}
3298
3299impl ToolCaller {
3300    pub(crate) fn label_pub(&self) -> String {
3301        if let Some(s) = &self.subagent {
3302            return format!("subagent:{s}");
3303        }
3304        if let (Some(r), Some(s)) = (&self.run, &self.step) {
3305            return format!("step:{r}/{s}");
3306        }
3307        format!(
3308            "ctx:{}",
3309            self.ctx.as_deref().unwrap_or(crate::context::ROOT)
3310        )
3311    }
3312}
3313
3314/// The start node `workflow.run` uses by default: `manual`, else the first.
3315fn default_start(w: &Workflow) -> Option<String> {
3316    let starts = w.start_steps();
3317    starts
3318        .iter()
3319        .find(|s| s.kind == "manual")
3320        .or_else(|| starts.first())
3321        .map(|s| s.id.clone())
3322}
3323
3324fn node_kind<'a>(w: &'a Workflow, node: &str) -> Option<&'a str> {
3325    w.step(node).map(|s| s.kind.as_str())
3326}
3327
3328fn run_detail(r: &RunState) -> Value {
3329    let mut v = r.summary();
3330    v["step_states"] = json!(r.steps);
3331    v["vars"] = Value::Object(r.vars.clone());
3332    v
3333}
3334
3335/// The `memory.<key>` roots a value's templates reference.
3336fn collect_memory_keys(v: &Value, out: &mut Vec<String>) {
3337    match v {
3338        Value::String(s) => {
3339            let mut rest = s.as_str();
3340            while let Some(i) = rest.find("memory.") {
3341                let after = &rest[i + "memory.".len()..];
3342                let key: String = after
3343                    .chars()
3344                    .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/' | ':'))
3345                    .collect();
3346                if !key.is_empty() && !out.contains(&key) {
3347                    out.push(key.clone());
3348                }
3349                rest = &after[key.len().min(after.len())..];
3350            }
3351        }
3352        Value::Array(a) => a.iter().for_each(|x| collect_memory_keys(x, out)),
3353        Value::Object(o) => o.values().for_each(|x| collect_memory_keys(x, out)),
3354        _ => {}
3355    }
3356}
3357
3358/// Expand a workflow directory into the files it contains.
3359///
3360/// `pattern` is a comma-separated list of shell-style globs relative to `dir`.
3361/// `**` crosses directory boundaries, so `**/*.yaml` walks the tree and
3362/// `*.yaml` does not — the distinction people already expect from every other
3363/// tool that takes a glob.
3364///
3365/// Results are SORTED. A directory listing is in whatever order the filesystem
3366/// feels like, and load order decides which of two same-named workflows is
3367/// reported as the duplicate — a diagnostic that changed between machines would
3368/// be worse than useless.
3369fn expand_dir(dir: &str, pattern: &str) -> Result<Vec<String>, String> {
3370    let root = std::path::Path::new(dir);
3371    if !root.is_dir() {
3372        return Err(format!("not a directory ({})", root.display()));
3373    }
3374    let pats: Vec<&str> = pattern
3375        .split(',')
3376        .map(str::trim)
3377        .filter(|p| !p.is_empty())
3378        .collect();
3379    let recursive = pats.iter().any(|p| p.contains("**"));
3380    let mut out = Vec::new();
3381    let mut stack = vec![root.to_path_buf()];
3382    while let Some(d) = stack.pop() {
3383        let rd = std::fs::read_dir(&d).map_err(|e| e.to_string())?;
3384        for ent in rd.flatten() {
3385            let path = ent.path();
3386            if path.is_dir() {
3387                if recursive {
3388                    stack.push(path);
3389                }
3390                continue;
3391            }
3392            let rel = path.strip_prefix(root).unwrap_or(&path);
3393            let rels = rel.to_string_lossy();
3394            if pats.iter().any(|p| glob_match(p, &rels)) {
3395                out.push(path.to_string_lossy().into_owned());
3396            }
3397        }
3398    }
3399    out.sort();
3400    Ok(out)
3401}
3402
3403/// Shell-style glob matching: `*` within a segment, `**` across segments, `?`
3404/// for one character. Small on purpose — a workflow directory does not need
3405/// brace expansion or character classes, and a dependency for this would be a
3406/// poor trade in a tree that counts them.
3407fn glob_match(pat: &str, text: &str) -> bool {
3408    // `**/x` should also match a bare `x` at the root: people write it meaning
3409    // "at any depth", which includes none.
3410    if let Some(rest) = pat.strip_prefix("**/")
3411        && glob_match(rest, text)
3412    {
3413        return true;
3414    }
3415    let (p, t): (Vec<char>, Vec<char>) = (pat.chars().collect(), text.chars().collect());
3416    fn go(p: &[char], t: &[char]) -> bool {
3417        match p.first() {
3418            None => t.is_empty(),
3419            Some('*') => {
3420                let doubled = p.get(1) == Some(&'*');
3421                let rest = if doubled { &p[2..] } else { &p[1..] };
3422                // A single `*` stops at a separator; `**` does not.
3423                let mut i = 0;
3424                loop {
3425                    if go(rest, &t[i..]) {
3426                        return true;
3427                    }
3428                    if i >= t.len() {
3429                        return false;
3430                    }
3431                    if !doubled && t[i] == '/' {
3432                        return false;
3433                    }
3434                    i += 1;
3435                }
3436            }
3437            Some('?') if !t.is_empty() => go(&p[1..], &t[1..]),
3438            Some(c) if t.first() == Some(c) => go(&p[1..], &t[1..]),
3439            _ => false,
3440        }
3441    }
3442    go(&p, &t)
3443}
3444
3445#[cfg(test)]
3446mod glob_tests {
3447    use super::glob_match;
3448
3449    #[test]
3450    fn a_single_star_stays_inside_one_segment_and_double_crosses() {
3451        // The distinction people expect from every other tool that takes a glob.
3452        assert!(glob_match("*.yaml", "nightly.yaml"));
3453        assert!(
3454            !glob_match("*.yaml", "team/nightly.yaml"),
3455            "* must not cross /"
3456        );
3457        assert!(glob_match("**/*.yaml", "team/nightly.yaml"));
3458        assert!(glob_match("**/*.yaml", "a/b/c/deep.yaml"));
3459        // `**/x` means "at any depth", and no depth is a depth — otherwise a
3460        // recursive pattern silently skips the files at the root.
3461        assert!(glob_match("**/*.yaml", "nightly.yaml"));
3462
3463        assert!(glob_match("flows/*.json", "flows/a.json"));
3464        assert!(!glob_match("flows/*.json", "flows/a.yaml"));
3465        assert!(!glob_match("*.yaml", "yaml"), "the dot is literal");
3466        assert!(glob_match("?.yaml", "a.yaml"));
3467        assert!(!glob_match("?.yaml", "ab.yaml"));
3468        // A pattern with no wildcard is an exact name.
3469        assert!(glob_match("nightly.yaml", "nightly.yaml"));
3470        assert!(!glob_match("nightly.yaml", "nightly.yml"));
3471    }
3472}