Skip to main content

agentd/runtime/
reactor.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **runtime state + event loop** (RFC 0026 §3, §8): one single-threaded
3//! reactor over child frames, reaped children, executor results, timers, the
4//! durable inbox and signals; state mutation happens only here (single
5//! writer); every mutation is followed by a checkpoint decision (RFC 0025 §5).
6//! The other `runtime::*` modules add `impl Runtime` blocks for turns, tools,
7//! steps and subagents; this file owns construction, the loop, lifecycle and
8//! the status view.
9
10use super::artifacts::Artifacts;
11use super::children::{ChildKind, Children};
12use super::events::{Event, kinds};
13use super::timers::Timers;
14use crate::config::v2::{RunUntil, Settings};
15use crate::context::memory::Memory;
16use crate::context::{Contexts, skills, tokens};
17use crate::engine::{RunState, RunStatus, Workflow};
18use crate::governor::Governor;
19use crate::mcp::client::McpClient;
20use crate::obs::log::Logger;
21use crate::registry::Registry;
22use crate::state::{Durable, InboxEvent, Kind, now_ms};
23use crate::subagent::protocol::AgentMsg;
24use crate::supervisor::reap::Reaped;
25use crate::supervisor::tree::NodeId;
26use serde_json::{Value, json};
27use std::collections::{BTreeMap, VecDeque};
28use std::sync::Arc;
29use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
30use std::time::{Duration, Instant};
31
32/// The reactor tick.
33pub const TICK: Duration = Duration::from_millis(200);
34/// Extra grace after the drain deadline before children are abandoned.
35pub const ABANDON_GRACE: Duration = Duration::from_secs(3);
36
37/// Who receives a deferred tool's answer.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Target {
40    /// A child's `ToolRequest` (answered with `ToolResult`).
41    Child(NodeId, u64),
42    /// A workflow step (answered as the step's outcome).
43    Step(String, String),
44}
45
46/// A deferred internal-tool request (answered when its wait resolves).
47#[derive(Debug, Clone)]
48pub struct PendingTool {
49    pub target: Target,
50    pub name: String,
51    pub kind: PendingKind,
52    pub started_ms: u64,
53}
54
55#[derive(Debug, Clone)]
56pub enum PendingKind {
57    /// A durable timer (`sleep`).
58    Timer { id: String },
59    /// A subagent result (`subagent.run` sync / `subagent.await`).
60    Subagent { handle: String },
61    /// A think child (`think` tool / `context.compact`).
62    Think { child: NodeId },
63    /// A run's terminal state (`workflow.run wait` / `workflow.wait`).
64    Run { run: String, deadline_ms: u64 },
65    /// A CEL condition polled each tick (`await`).
66    Await { condition: String, deadline_ms: u64 },
67    /// A human's answer (`ask_human` / the `human` node — RFC 0032 §16): the
68    /// A2A task `task` sits in `input-required`; a `SendMessage` carrying its
69    /// `taskId` resolves this with the reply text. With no interface to answer
70    /// on, `task` is a synthetic ask id (no A2A task exists).
71    Human {
72        task: String,
73        question: String,
74        deadline_ms: u64,
75        /// The task exists ONLY for this ask (no A2A caller/run owns it) —
76        /// complete it when the answer lands.
77        standalone: bool,
78        /// The `auto` fallback judge is running (or already ran) for this ask.
79        auto_fired: bool,
80    },
81}
82
83/// A queued root/conversation turn (RFC 0026 §3.2), waiting for a slot and
84/// for its context to be free.
85#[derive(Debug, Clone)]
86pub struct TurnJob {
87    pub ctx: String,
88    /// The triggering inbox event (marked done when the turn completes).
89    pub event: Option<String>,
90    pub principal: Option<String>,
91    /// The message appended to the context before the turn (already appended
92    /// when `None`).
93    pub message: Option<crate::context::Msg>,
94    /// Skill references to preload.
95    pub skills: Vec<String>,
96    /// The user text (for preflight / knowledge retrieval).
97    pub text: String,
98    /// Preflight ran (or was not needed).
99    pub preflight_done: bool,
100    /// Knowledge auto-context ran (or was not needed).
101    pub knowledge_done: bool,
102    /// The retrieved knowledge block (system message) for this turn.
103    pub knowledge: Option<String>,
104}
105
106impl TurnJob {
107    pub fn new(
108        ctx: String,
109        event: Option<String>,
110        principal: Option<String>,
111        message: Option<crate::context::Msg>,
112        skills: Vec<String>,
113        text: String,
114    ) -> TurnJob {
115        TurnJob {
116            ctx,
117            event,
118            principal,
119            message,
120            skills,
121            text,
122            preflight_done: false,
123            knowledge_done: false,
124            knowledge: None,
125        }
126    }
127}
128
129/// A subagent registry record (RFC 0026 §6; durable `subagent/<handle>`).
130#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131pub struct SubagentRecord {
132    pub handle: String,
133    pub instruction: String,
134    pub mode: String,
135    pub status: String,
136    #[serde(default)]
137    pub attempt: u32,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub result: Option<Value>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub error: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub requested_by: Option<Value>,
144    #[serde(default)]
145    pub tokens: u64,
146    #[serde(default)]
147    pub created: u64,
148    #[serde(default)]
149    pub updated: u64,
150    /// The payload (secret-free) for restore re-spawn.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub payload: Option<Value>,
153    #[serde(skip)]
154    pub node: Option<NodeId>,
155    #[serde(skip)]
156    pub dirty: bool,
157}
158
159/// The current instruction (RFC 0028 §3 `instruction.*`).
160#[derive(Debug, Clone)]
161pub struct Instruction {
162    pub text: String,
163    pub source: &'static str,
164    pub uri: Option<String>,
165    pub server: Option<String>,
166    pub version: u64,
167}
168
169/// Counters for status/reports.
170#[derive(Debug, Default, Clone)]
171pub struct Counters {
172    pub turns: u64,
173    pub tool_calls: u64,
174    pub runs_started: u64,
175    pub runs_finished: u64,
176    pub inbox_processed: u64,
177    pub tokens_in: u64,
178    pub tokens_out: u64,
179}
180
181pub struct Runtime {
182    pub(crate) settings: Settings,
183    /// The merged document the settings came from (restart-only diff base).
184    pub(crate) settings_doc: Value,
185    /// The invocation (for reload).
186    pub(crate) args: Vec<String>,
187    pub(crate) env: Vec<(String, String)>,
188    /// Workflow definitions pinned by live runs after a reload (hash → definition).
189    pub(crate) pinned: BTreeMap<String, Workflow>,
190    /// The last payload per signal name (for `await`/`wait condition` views).
191    pub(crate) recent_signals: BTreeMap<String, Value>,
192    pub(crate) log: Logger,
193    pub(crate) instance: String,
194    pub(crate) run_id: String,
195    pub(crate) durable: Durable,
196    pub(crate) mcp: BTreeMap<String, Arc<McpClient>>,
197    pub(crate) mcp_specs: BTreeMap<String, crate::config::McpServerSpec>,
198    pub(crate) registry: Registry,
199    pub(crate) contexts: Contexts,
200    pub(crate) memory: Memory,
201    pub(crate) artifacts: Artifacts,
202    pub(crate) skills: skills::Catalogue,
203    pub(crate) governor: Governor,
204    pub(crate) workflows: BTreeMap<String, Workflow>,
205    pub(crate) runs: BTreeMap<String, RunState>,
206    pub(crate) children: Children,
207    pub(crate) timers: Timers,
208    pub(crate) events_rx: Receiver<Event>,
209    pub(crate) events_tx: Sender<Event>,
210    pub(crate) child_rx: Receiver<(NodeId, AgentMsg)>,
211    pub(crate) reap_rx: Receiver<Reaped>,
212    pub(crate) pending: Vec<PendingTool>,
213    pub(crate) turn_queue: VecDeque<TurnJob>,
214    /// Turn jobs parked while their preflight think / knowledge retrieval runs.
215    pub(crate) staged_turns: BTreeMap<u64, TurnJob>,
216    pub(crate) inbox_queue: VecDeque<InboxEvent>,
217    pub(crate) subagents: BTreeMap<String, SubagentRecord>,
218    pub(crate) instruction: Instruction,
219    pub(crate) job_shape: bool,
220    pub(crate) exit: Option<i32>,
221    pub(crate) draining: bool,
222    /// Operator-held (a2a.pause): intake continues; no new turns dispatch and
223    /// no steps schedule until a2a.resume. Reversible, unlike drain.
224    pub(crate) paused: bool,
225    pub(crate) drain_started: Option<Instant>,
226    pub(crate) drain_reason: String,
227    pub(crate) idle_since: Option<Instant>,
228    pub(crate) intel_uri: String,
229    pub(crate) intel_token: Option<String>,
230    /// Resolved `intelligence.headers` (RFC 0031), pushed on every LLM dial and
231    /// threaded to subagents via the spawn payload.
232    pub(crate) intel_headers: Vec<(String, String)>,
233    /// An optional intelligence OAuth credential provider (RFC 0031): a closure
234    /// returning the current bearer (refreshing from the device-login cache). The
235    /// resolved bearer overrides `intel_token` and is threaded to subagents fresh
236    /// at each spawn. `None` when no `intelligence.auth` oauth2 block is set.
237    pub(crate) intel_bearer: Option<std::sync::Arc<dyn Fn() -> Option<String> + Send + Sync>>,
238    pub(crate) model: String,
239    pub(crate) trace_id: Option<String>,
240    pub(crate) started: Instant,
241    pub(crate) seq: u64,
242    pub(crate) counters: Counters,
243    /// The `once`-started run(s) whose finish decides a job's exit code.
244    pub(crate) job_runs: Vec<String>,
245    /// Steps executing on executor threads (`run/step` → started).
246    pub(crate) executing: BTreeMap<String, Instant>,
247    pub(crate) last_manifest_flush: Instant,
248    /// Unix-ms a goal LLM judge was dispatched (so overlapping checks don't spawn
249    /// duplicate judges); `None` = none in flight.
250    pub(crate) goal_judge_at: Option<u64>,
251    /// Durable A2A tasks (RFC 0029 §4), keyed by task id.
252    #[cfg(feature = "a2a")]
253    pub(crate) tasks: BTreeMap<String, crate::a2a::Task>,
254    /// Inbox-event id → the A2A task it answers (a conversation turn).
255    #[cfg(feature = "a2a")]
256    pub(crate) event_to_task: BTreeMap<String, String>,
257    /// The task snapshot the A2A listener threads read (None ⇒ not serving).
258    #[cfg(feature = "a2a")]
259    /// The interface event feed (RFC 0032; None ⇒ interface disabled).
260    #[cfg(feature = "a2a")]
261    pub(crate) a2a_feed: Option<std::sync::Arc<super::a2a_server::SharedFeed>>,
262    /// Pairing-code login (RFC 0032 §13; None ⇒ pairing disabled).
263    #[cfg(feature = "a2a")]
264    pub(crate) a2a_pairing: Option<std::sync::Arc<super::a2a_server::PairingState>>,
265    /// The id the listener reserved for the task the request being served will
266    /// create. Taken by the first `task_create` of that request, and cleared
267    /// after it — an id belongs to one request only.
268    #[cfg(feature = "a2a")]
269    pub(crate) reserved_task_id: Option<String>,
270    /// Where a task transition is published so A2A subscribers see it.
271    #[cfg(feature = "a2a")]
272    pub(crate) a2a_sink: Option<std::sync::Arc<crate::a2a::ports::StreamSink>>,
273    /// The live listener. Held, not used: dropping it stops serving.
274    #[cfg(feature = "a2a")]
275    pub(crate) a2a_listener: Option<crate::a2a::serve::Listener>,
276    /// Live per-unit activity (RFC 0032 §17), keyed by child node id.
277    pub(crate) activity: BTreeMap<u64, super::activity::Activity>,
278    /// The newest root-context reply, so a `--prompt` job can print its answer
279    /// (a prompt runs as a turn, not as a `once` run with an output).
280    pub(crate) last_root_reply: Option<String>,
281    /// Per-item fingerprints behind the feed's section diffing (`feed_tick`).
282    #[cfg(feature = "a2a")]
283    pub(crate) feed_marks: BTreeMap<String, u64>,
284    /// The last section-diff pass (rate-limits `feed_tick`).
285    #[cfg(feature = "a2a")]
286    pub(crate) feed_last: Instant,
287    /// The `wait: {on: webhook}` await-callback registry, shared with the webhook
288    /// listener threads.
289    #[cfg(feature = "a2a")]
290    pub(crate) webhook_callbacks: super::webhooks::SharedCallbacks,
291    /// Pending `respond: sync` webhook replies, keyed by the run id they await.
292    #[cfg(feature = "a2a")]
293    pub(crate) webhook_sync: std::collections::HashMap<
294        String,
295        std::sync::mpsc::SyncSender<super::webhooks::WebhookReply>,
296    >,
297}
298
299impl Runtime {
300    /// A fresh id (turn ids, handles).
301    pub(crate) fn next_id(&mut self, prefix: &str) -> String {
302        self.seq += 1;
303        format!("{prefix}-{}", self.seq)
304    }
305
306    // ---- the loop ----------------------------------------------------------
307
308    /// Run until exit. Returns the process exit code.
309    pub fn run_loop(&mut self) -> i32 {
310        self.log.info("proc.ready", json!({"instance": self.instance, "job_shape": self.job_shape, "workflows": self.workflows.len(), "runs": self.runs.len(), "inbox_pending": self.inbox_queue.len()}));
311        loop {
312            crate::obs::health::tick();
313            // 1. Child frames.
314            while let Ok((node, msg)) = self.child_rx.try_recv() {
315                self.on_child_frame(node, msg);
316            }
317            // 2. Reaped children.
318            let _ = crate::signals::take_child_exit();
319            crate::supervisor::reaper::reap_and_dispatch();
320            while let Ok(r) = self.reap_rx.try_recv() {
321                self.on_reaped(r);
322            }
323            // 3. Executor / internal events.
324            while let Ok(ev) = self.events_rx.try_recv() {
325                self.on_event(ev);
326            }
327            // 4. Timers.
328            let now = now_ms();
329            for t in self.timers.fire(&self.durable, now) {
330                self.on_timer(t);
331            }
332            // 5. The inbox.
333            self.process_inbox();
334            // 6. Start nodes + runs (+ suspended waits).
335            self.poll_starts();
336            self.poll_waits();
337            self.schedule_runs();
338            // 7. Turns.
339            self.dispatch_turns();
340            // 8. Pending waits + MCP notifications.
341            self.poll_pending();
342            self.poll_mcp_notifications();
343            // 9. Children maintenance.
344            for (node, health) in self.children.tick() {
345                self.on_unhealthy_child(node, health);
346            }
347            // 10. Checkpoints + the point-in-time observability gauges (§3.11).
348            self.checkpoint(false);
349            crate::obs::metrics::set_inbox_pending(self.inbox_queue.len() as u64);
350            crate::obs::metrics::set_context_tokens(self.contexts.max_est_tokens());
351            // 10.5. The interface feed's section diff (RFC 0032 §4): publish
352            // run/conversation/subagent/child/status deltas to attached display
353            // clients. A no-op unless `interface.enabled`; rate-limited inside.
354            #[cfg(feature = "a2a")]
355            self.feed_tick();
356            // 11. Signals + lifecycle.
357            self.check_signals();
358            if let Some(code) = self.lifecycle_step() {
359                self.shutdown(code);
360                return code;
361            }
362            // 12. Wait for the next event, bounded by the tick or the nearest
363            // imminent deadline (a timer, a schedule/loop start, a pending wait)
364            // so time-based work fires promptly rather than at tick granularity.
365            crate::signals::drain_wakeup();
366            let wait = self.next_wake().min(TICK);
367            match self.events_rx.recv_timeout(wait) {
368                Ok(ev) => self.on_event(ev),
369                Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => {}
370            }
371        }
372    }
373
374    fn on_event(&mut self, ev: Event) {
375        match ev {
376            Event::Child(node, msg) => self.on_child_frame(node, msg),
377            Event::Reaped(r) => self.on_reaped(r),
378            Event::StepDone {
379                run,
380                step,
381                output,
382                is_error,
383                error,
384                tokens,
385            } => self.on_step_done(&run, &step, output, is_error, error, tokens),
386            Event::ToolDone {
387                node,
388                req,
389                result,
390                is_error,
391            } => self.on_tool_done(node, req, result, is_error),
392            Event::KnowledgeDone { job, block } => self.on_knowledge_done(job, block),
393            Event::TimerFired { id, owner, payload } => self.on_timer(crate::state::TimerRecord {
394                id,
395                deadline_ms: now_ms(),
396                owner,
397                payload,
398            }),
399            Event::Inbox(ev) => self.inbox_queue.push_back(ev),
400            #[cfg(feature = "a2a")]
401            Event::A2a(req) => self.on_a2a_request(*req),
402            #[cfg(feature = "a2a")]
403            Event::Webhook(req) => self.on_webhook_request(*req),
404            Event::Background { id, result } if id == "goal.judge" => self.on_goal_judge(&result),
405            Event::Background { id, result } if id.starts_with("human.judge:") => {
406                let ask = id.trim_start_matches("human.judge:").to_string();
407                self.on_human_judge(&ask, &result);
408            }
409            Event::Background { .. } | Event::Tick => {}
410        }
411    }
412
413    // ---- inbox -------------------------------------------------------------
414
415    /// Accept a durable event: write-ahead, then queue (RFC 0025 §5).
416    pub(crate) fn accept_event(
417        &mut self,
418        kind: &str,
419        principal: Option<String>,
420        payload: Value,
421    ) -> Result<String, String> {
422        let ev = InboxEvent::new(kind, principal, payload);
423        self.durable
424            .inbox_put(&ev)
425            .map_err(|e| format!("inbox: {e}"))?;
426        let id = ev.id.clone();
427        self.log
428            .info("inbox.accepted", json!({"inbox_event": id, "kind": kind}));
429        self.inbox_queue.push_back(ev);
430        Ok(id)
431    }
432
433    fn process_inbox(&mut self) {
434        // Drain a SNAPSHOT, never the live deque: a start event that overflows
435        // its workflow's concurrency cap re-queues itself (`on_overflow: queue`,
436        // the default), and the cap can only be relieved by `schedule_runs` — a
437        // LATER step of this tick. Popping from the same deque the requeue
438        // pushes onto re-offers the event immediately and the single-writer
439        // reactor spins at 100% CPU forever: no timers, no checkpoint, no
440        // SIGTERM. Requeued (and newly accepted) events land in the fresh
441        // `self.inbox_queue` and are retried on the next tick instead.
442        let mut batch = std::mem::take(&mut self.inbox_queue);
443        while let Some(ev) = batch.pop_front() {
444            if self.draining {
445                // Keep it durable for the next life; stop intake.
446                batch.push_front(ev);
447                break;
448            }
449            self.counters.inbox_processed += 1;
450            match ev.kind.as_str() {
451                kinds::START_FIRED | kinds::WORKFLOW_RUN => {
452                    let done = self.on_start_event(&ev);
453                    if done {
454                        self.inbox_done(&ev.id);
455                    }
456                }
457                kinds::A2A_MESSAGE => {
458                    // P5 wires the A2A server; a replayed message still becomes a turn.
459                    self.on_a2a_message_event(&ev);
460                }
461                kinds::SIGNAL => {
462                    let name = ev.payload["name"].as_str().unwrap_or("").to_string();
463                    let payload = ev.payload.get("payload").cloned().unwrap_or(Value::Null);
464                    let target = ev
465                        .payload
466                        .get("run")
467                        .and_then(Value::as_str)
468                        .map(str::to_string);
469                    let from = ev
470                        .payload
471                        .get("from")
472                        .and_then(Value::as_str)
473                        .map(str::to_string);
474                    let delivered =
475                        self.deliver_signal(&name, payload, target.as_deref(), from.as_deref());
476                    self.log.info(
477                        "signal.received",
478                        json!({"inbox_event": ev.id, "name": name, "delivered": delivered}),
479                    );
480                    self.inbox_done(&ev.id);
481                }
482                other => {
483                    self.log.warn(
484                        "inbox.unknown_kind",
485                        json!({"inbox_event": ev.id, "kind": other}),
486                    );
487                    self.inbox_done(&ev.id);
488                }
489            }
490        }
491        // Whatever the drain did not consume keeps its place ahead of the
492        // events requeued (or accepted) while the batch was processing.
493        batch.append(&mut self.inbox_queue);
494        self.inbox_queue = batch;
495    }
496
497    pub(crate) fn inbox_done(&mut self, id: &str) {
498        if let Err(e) = self.durable.inbox_done(id) {
499            self.log.warn(
500                "inbox.done.fail",
501                json!({"inbox_event": id, "err": e.to_string()}),
502            );
503        }
504    }
505
506    /// An A2A message event → a conversation turn (RFC 0026 §3.2). P5 adds
507    /// commands/authorization; here every message is natural language.
508    fn on_a2a_message_event(&mut self, ev: &InboxEvent) {
509        let ctx = ev.payload["context_id"]
510            .as_str()
511            .unwrap_or("default")
512            .to_string();
513        let text = ev.payload["text"]
514            .as_str()
515            .map(str::to_string)
516            .unwrap_or_else(|| ev.payload["parts"].to_string());
517        let principal = ev.principal.clone();
518        // Re-link a replayed message to its durable task (crash recovery).
519        #[cfg(feature = "a2a")]
520        if let Some(task_id) = ev.payload["task"].as_str() {
521            self.event_to_task
522                .insert(ev.id.clone(), task_id.to_string());
523        }
524        let skills = self.skills.references(&text);
525        self.turn_queue.push_back(TurnJob::new(
526            ctx,
527            Some(ev.id.clone()),
528            principal.clone(),
529            Some(crate::context::Msg::user(text.clone(), principal)),
530            skills,
531            text,
532        ));
533    }
534
535    // ---- children ----------------------------------------------------------
536
537    fn on_child_frame(&mut self, node: NodeId, msg: AgentMsg) {
538        if !self.children.on_frame(node, &msg) {
539            return; // a late frame from a reaped child
540        }
541        match msg {
542            AgentMsg::Ready
543            | AgentMsg::Pong { .. }
544            | AgentMsg::Gate { .. }
545            | AgentMsg::GateClosed { .. } => {}
546            // Coarse progress from the child (RFC 0032 §17): what this unit is
547            // doing right now, for the display clients' working row.
548            AgentMsg::Event { event, fields } => self.on_child_progress(node, &event, &fields),
549            AgentMsg::Usage(u) => {
550                self.counters.tokens_in += u.input_tokens;
551                self.counters.tokens_out += u.output_tokens;
552                crate::obs::metrics::record_tokens(u.input_tokens, u.output_tokens);
553                // A subagent's usage is charged as it reports; turn usage is
554                // settled on TurnDone against its reservation.
555                if let Some(ChildKind::Subagent { .. }) = self.children.get(node).map(|c| &c.kind) {
556                    self.governor.charge(u, &[]);
557                }
558            }
559            AgentMsg::IntelHealth { all_down, .. } => {
560                if crate::signals::set_intel_all_down(all_down) {
561                    self.log.warn("intel.health", json!({"all_down": all_down}));
562                }
563            }
564            AgentMsg::ToolRequest { id, name, args } => self.on_tool_request(node, id, &name, args),
565            AgentMsg::BudgetRequest { id, estimate } => self.on_budget_request(node, id, estimate),
566            AgentMsg::TurnDone { turn } => self.on_turn_done(node, *turn),
567            AgentMsg::Turn { outcome } => self.on_subagent_turn(node, outcome),
568            AgentMsg::Result { outcome } => self.on_subagent_result(node, Ok(outcome)),
569            AgentMsg::Failed { error } => {
570                let kind = self.children.get(node).map(|c| c.kind.clone());
571                match kind {
572                    Some(ChildKind::Subagent { .. }) => self.on_subagent_result(node, Err(error)),
573                    Some(_) => self.on_turn_failed(node, error),
574                    None => {}
575                }
576            }
577        }
578    }
579
580    fn on_reaped(&mut self, r: Reaped) {
581        let Some((node, child)) = self.children.on_reaped(&r) else {
582            return;
583        };
584        self.activity_end(node);
585        self.log.info("child.exit", json!({"node": node.0, "pid": r.pid, "kind": super::children::kind_label(&child.kind), "outcome": format!("{:?}", r.outcome)}));
586        // A child that died without its terminal frame: fail its unit.
587        match child.kind {
588            // The old guard asked `pending_turn_exists` — "is the child still in
589            // the table?" — which cannot answer this question from here: a
590            // `TurnDone` settles the step but leaves the child in the table
591            // until it is reaped, and `Children::on_reaped` above has already
592            // removed it, so the guard is false for settled and orphaned workers
593            // alike and the step stayed Running forever. The STEP can answer it:
594            // it is Running and still owned by THIS worker only when no terminal
595            // frame ever landed.
596            ChildKind::StepTurn {
597                ref run,
598                ref step,
599                reservation,
600            } => {
601                let node_owned = node.0.to_string();
602                let orphaned = self
603                    .runs
604                    .get(run)
605                    .and_then(|st| st.step(step))
606                    .is_some_and(|s| {
607                        s.status == crate::engine::StepStatus::Running
608                            && s.worker.as_deref() == Some(node_owned.as_str())
609                    });
610                if orphaned {
611                    // `on_turn_failed` would route this, but it re-reads the
612                    // child table too and returns early on the reaped node; the
613                    // reservation it would have released is released here.
614                    if let Some(res) = reservation {
615                        self.governor.release(res);
616                    }
617                    self.log.warn(
618                        "turn.failed",
619                        json!({"node": node.0, "kind": super::children::kind_label(&child.kind), "err": "worker exited without a result"}),
620                    );
621                    self.on_step_turn_done(
622                        run,
623                        step,
624                        crate::subagent::protocol::TurnResult {
625                            status: "failed".into(),
626                            error: Some(format!(
627                                "worker exited without a result ({:?})",
628                                r.outcome
629                            )),
630                            ..Default::default()
631                        },
632                    );
633                }
634            }
635            // A root turn and a think expose no equivalent state to test here,
636            // so they ask `pending_turn_exists` — which now answers from the
637            // settled marker `on_turn_done`/`on_turn_failed` leave on the child
638            // record, not from the child's presence in the table. Presence
639            // cannot answer it: `on_reaped` has already removed the child by the
640            // time this runs, and a normally-settled worker also stays in the
641            // table until it is reaped, so the old form read false for settled
642            // and orphaned workers alike and this arm never fired.
643            ChildKind::RootTurn { .. } | ChildKind::Think { .. } => {
644                if self.pending_turn_exists(node) {
645                    self.on_turn_failed(
646                        node,
647                        format!("worker exited without a result ({:?})", r.outcome),
648                    );
649                }
650            }
651            ChildKind::Subagent { ref handle } => {
652                if self
653                    .subagents
654                    .get(handle)
655                    .is_some_and(|s| !is_terminal_status(&s.status))
656                {
657                    self.on_subagent_result(
658                        node,
659                        Err(format!(
660                            "subagent exited without a result ({:?})",
661                            r.outcome
662                        )),
663                    );
664                }
665            }
666        }
667        // Answer any tool request that was waiting on this child (a think).
668        let waiting: Vec<PendingTool> = self
669            .pending
670            .iter()
671            .filter(|p| matches!(&p.kind, PendingKind::Think { child } if *child == node))
672            .cloned()
673            .collect();
674        for p in waiting {
675            self.pending.retain(|q| q.target != p.target);
676            self.reply(
677                &p.target,
678                Value::String("think worker exited without a result".into()),
679                true,
680            );
681        }
682    }
683
684    fn on_unhealthy_child(&mut self, node: NodeId, health: crate::supervisor::liveness::Health) {
685        self.log.warn(
686            "child.unhealthy",
687            json!({"node": node.0, "health": format!("{health:?}")}),
688        );
689        self.children.cancel(node, &format!("{health:?}"));
690        // Escalate: give it a moment, then kill.
691        let started = self
692            .children
693            .get(node)
694            .map(|c| c.started)
695            .unwrap_or_else(Instant::now);
696        if started.elapsed() > Duration::from_secs(1) {
697            self.children.kill(node);
698        }
699    }
700
701    // ---- lifecycle ---------------------------------------------------------
702
703    fn check_signals(&mut self) {
704        if crate::signals::draining() && !self.draining {
705            self.begin_drain("signal");
706        }
707        if crate::signals::reload_requested() {
708            crate::signals::clear_reload();
709            self.on_reload_requested();
710        }
711    }
712
713    pub(crate) fn begin_drain(&mut self, reason: &str) {
714        if self.draining {
715            return;
716        }
717        self.draining = true;
718        self.drain_started = Some(Instant::now());
719        self.drain_reason = reason.to_string();
720        crate::signals::set_lame_duck(true);
721        self.log.info("drain.start", json!({"reason": reason, "children": self.children.len(), "runs": self.runs.values().filter(|r| !r.status.is_terminal()).count()}));
722        crate::obs::metrics::record_drain("started");
723        // Tell every attached display client (RFC 0032 §4).
724        #[cfg(feature = "a2a")]
725        self.feed_push(
726            "lifecycle",
727            super::a2a_server::FeedVis::All,
728            json!({"draining": true, "reason": reason}),
729        );
730        self.children.begin_drain(reason);
731    }
732
733    /// Decide whether to exit now. Returns the exit code when done.
734    fn lifecycle_step(&mut self) -> Option<i32> {
735        if let Some(code) = self.exit {
736            // A `finish {exit: true}` or a fatal store failure asked to exit:
737            // drain first.
738            if !self.draining {
739                self.begin_drain("exit");
740            }
741            if self.children.is_empty() {
742                return Some(code);
743            }
744        }
745        if self.draining {
746            let timeout = self.settings.lifecycle.drain_timeout();
747            let started = self.drain_started.unwrap_or_else(Instant::now);
748            let force = crate::signals::force() || started.elapsed() >= timeout;
749            let done = self.children.drive_drain(force);
750            if done || started.elapsed() >= timeout + ABANDON_GRACE {
751                if !done {
752                    self.log
753                        .warn("drain.abandon", json!({"children": self.children.len()}));
754                    self.children.abandon();
755                }
756                crate::obs::metrics::record_drain("completed");
757                self.checkpoint(true);
758                self.log
759                    .info("drain.done", json!({"reason": self.drain_reason}));
760                return Some(self.exit.unwrap_or(crate::exit::SUCCESS));
761            }
762            return None;
763        }
764        // Job shape / idle policy.
765        let run_until = self.settings.lifecycle.run_until;
766        // `auto` re-reads the LIVE workflow set, not just the configured one:
767        // a long-lived workflow the agent defined at runtime (`workflow.create`
768        // — the self-setup shape, where a `--prompt` tells it to build its own
769        // loop/schedule/subscribe) turns the one-shot job into a daemon exactly
770        // as a configured one would have. Without this the instance idle-exits
771        // out from under the thing it was just asked to set up.
772        let job_now = self.job_shape && !self.workflows.values().any(|w| w.is_long_lived());
773        let idle_policy = match run_until {
774            RunUntil::Idle => true,
775            RunUntil::Drained => false,
776            RunUntil::Auto => job_now,
777        };
778        if !idle_policy {
779            return None;
780        }
781        let busy = self.paused // a paused instance never idle-exits underneath the operator
782            || !self.children.is_empty()
783            || !self.turn_queue.is_empty()
784            || !self.staged_turns.is_empty()
785            || !self.inbox_queue.is_empty()
786            || !self.pending.is_empty()
787            || !self.executing.is_empty()
788            || self.runs.values().any(|r| !r.status.is_terminal())
789            || !self.timers.is_empty();
790        if busy {
791            self.idle_since = None;
792            return None;
793        }
794        let since = *self.idle_since.get_or_insert_with(Instant::now);
795        if since.elapsed() >= self.settings.lifecycle.idle_grace() || job_now {
796            let code = self.job_exit_code();
797            self.log.info(
798                "lifecycle.idle_exit",
799                json!({"code": code, "job_shape": self.job_shape}),
800            );
801            self.checkpoint(true);
802            return Some(code);
803        }
804        None
805    }
806
807    /// The exit code of a job-shaped instance: the once-started workflow's
808    /// finish status (RFC 0011 §5 mapping); a daemon drains to 0.
809    fn job_exit_code(&self) -> i32 {
810        let mut code = crate::exit::SUCCESS;
811        for id in &self.job_runs {
812            if let Some(r) = self.runs.get(id) {
813                let c = run_exit_code(r);
814                if c != crate::exit::SUCCESS {
815                    code = c;
816                }
817            }
818        }
819        if self.job_runs.is_empty() && self.job_shape {
820            // Nothing ever ran (no workflow fired) — a configuration edge; report success.
821            return crate::exit::SUCCESS;
822        }
823        crate::exit::apply_budget_remap(
824            code,
825            self.settings
826                .lifecycle
827                .exit_code_map
828                .get(&code.to_string())
829                .copied(),
830        )
831    }
832
833    fn shutdown(&mut self, code: i32) {
834        self.children.abandon();
835        let _ = self.durable.flush(true);
836        self.log.info("proc.exit", json!({"code": code, "uptime_ms": self.started.elapsed().as_millis() as u64, "turns": self.counters.turns, "tool_calls": self.counters.tool_calls, "runs": self.counters.runs_finished, "tokens_in": self.counters.tokens_in, "tokens_out": self.counters.tokens_out}));
837    }
838
839    /// The job's result (the once-started run's output), for stdout.
840    pub fn job_output(&self) -> Option<Value> {
841        self.job_runs
842            .iter()
843            .rev()
844            .filter_map(|id| self.runs.get(id))
845            .find_map(|r| r.output.clone())
846            // A `--prompt` job has no `once` run to carry an output: its answer
847            // is the root turn's reply.
848            .or_else(|| self.last_root_reply.clone().map(Value::String))
849    }
850
851    // ---- checkpoints ---------------------------------------------------------
852
853    /// Persist dirty runs/contexts/subagents; flush the manifest (debounced,
854    /// forced at drain). A halting store error triggers an exit.
855    pub(crate) fn checkpoint(&mut self, force: bool) {
856        let mut failed: Option<String> = None;
857        for run in self.runs.values_mut() {
858            if run.dirty {
859                crate::state::kill_point("step.before_done");
860                match self.durable.put(
861                    Kind::Run,
862                    &run.id,
863                    serde_json::to_value(&*run).unwrap_or(Value::Null),
864                    Some(run.workflow_hash.clone()),
865                ) {
866                    Ok(_) => run.dirty = false,
867                    Err(e) => failed = Some(format!("run {}: {e}", run.id)),
868                }
869            }
870        }
871        if let Err(e) = self.contexts.checkpoint(&self.durable) {
872            failed = Some(format!("context: {e}"));
873        }
874        for s in self.subagents.values_mut() {
875            if s.dirty {
876                match self.durable.put(
877                    Kind::Subagent,
878                    &s.handle,
879                    serde_json::to_value(&*s).unwrap_or(Value::Null),
880                    None,
881                ) {
882                    Ok(_) => s.dirty = false,
883                    Err(e) => failed = Some(format!("subagent {}: {e}", s.handle)),
884                }
885            }
886        }
887        // Manifest: budget counters + lifecycle, debounced.
888        let budget = self.governor.to_value();
889        self.durable.manifest_update(|m| {
890            m.budget = budget;
891        });
892        match self.durable.flush(force) {
893            Ok(_) => {}
894            Err(e) => failed = Some(format!("manifest: {e}")),
895        }
896        if let Some(e) = failed {
897            self.log.error("store.checkpoint.fail", json!({"err": e}));
898            if !self.durable.is_degraded() {
899                // Halt policy: refuse new intake, drain.
900                self.exit = Some(crate::exit::GENERIC);
901            }
902        }
903    }
904
905    // ---- status ------------------------------------------------------------
906
907    /// `status` tool / `agent://status`.
908    pub(crate) fn status_value(&self) -> Value {
909        json!({
910            "instance": self.instance,
911            "run_id": self.run_id,
912            "uptime_ms": self.started.elapsed().as_millis() as u64,
913            "job_shape": self.job_shape,
914            "draining": self.draining,
915            "paused": self.paused,
916            "store": {"kind": self.durable.store_kind(), "degraded": self.durable.is_degraded(), "generation": self.durable.manifest().generation},
917            "workflows": self.workflows.values().map(|w| json!({"name": w.name, "hash": w.hash, "armed": w.armed, "starts": w.start_steps().iter().map(|s| s.kind.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
918            "runs": self.runs.values().map(RunState::summary).collect::<Vec<_>>(),
919            "conversations": self.contexts.status(),
920            "subagents": self.subagents.values().map(|s| json!({"handle": s.handle, "mode": s.mode, "status": s.status, "tokens": s.tokens})).collect::<Vec<_>>(),
921            "children": self.children.status(),
922            "timers": self.timers.status(),
923            "inbox_pending": self.inbox_queue.len(),
924            "budget": self.governor.status(now_ms()),
925            "tools": self.registry.len(),
926            "skills": self.skills.names(),
927            "counters": {"turns": self.counters.turns, "tool_calls": self.counters.tool_calls, "runs_started": self.counters.runs_started, "runs_finished": self.counters.runs_finished, "tokens_in": self.counters.tokens_in, "tokens_out": self.counters.tokens_out},
928            "instruction": {"source": self.instruction.source, "uri": self.instruction.uri, "version": self.instruction.version, "bytes": self.instruction.text.len()},
929            "model": self.model,
930            "activity": self.activity_value(),
931        })
932    }
933
934    /// The shortest time until the next time-based wake (a timer, an armed
935    /// schedule/loop start, a suspended wait deadline, a budget wait). Bounded
936    /// below at 5 ms so a due deadline is serviced on the next pass without a
937    /// busy spin.
938    fn next_wake(&self) -> Duration {
939        let now = now_ms();
940        let mut soonest = now + 200;
941        if let Some(t) = self.timers.next_deadline() {
942            soonest = soonest.min(t);
943        }
944        for st in self.durable.manifest().starts.values() {
945            for k in ["next_ms", "debounce_until"] {
946                if let Some(n) = st[k].as_u64() {
947                    soonest = soonest.min(n);
948                }
949            }
950        }
951        for run in self.runs.values() {
952            if run.status.is_terminal() {
953                continue;
954            }
955            for step in run.steps.values() {
956                if let Some(w) = &step.wait
957                    && let Some(d) = w["deadline_ms"].as_u64()
958                {
959                    soonest = soonest.min(d);
960                }
961            }
962        }
963        if !self.pending.is_empty() || !self.turn_queue.is_empty() {
964            soonest = soonest.min(now + 50);
965        }
966        Duration::from_millis(soonest.saturating_sub(now).max(5))
967    }
968
969    /// The model window (compaction threshold base): `context.model_window`
970    /// when set, else inferred from the model name.
971    pub(crate) fn model_window(&self) -> u64 {
972        self.settings
973            .context
974            .model_window
975            .unwrap_or_else(|| tokens::window_for_model(&self.model))
976    }
977}
978
979pub(crate) fn is_terminal_status(s: &str) -> bool {
980    matches!(
981        s,
982        "completed" | "failed" | "cancelled" | "refused" | "killed" | "crashed"
983    )
984}
985
986/// RFC 0011 §5 exit mapping for a finished run.
987pub fn run_exit_code(r: &RunState) -> i32 {
988    match r.status {
989        RunStatus::Completed => crate::exit::SUCCESS,
990        RunStatus::Refused => crate::exit::REFUSED,
991        RunStatus::Stalled => crate::exit::PARTIAL,
992        RunStatus::Failed => {
993            let e = r.error.as_deref().unwrap_or("");
994            if e.contains("exhausted") || e.contains("budget") {
995                crate::exit::BUDGET
996            } else if e.contains("deadline") {
997                crate::exit::DEADLINE
998            } else if e.contains("intel") {
999                crate::exit::INTEL_UNAVAILABLE
1000            } else {
1001                crate::exit::GENERIC
1002            }
1003        }
1004        RunStatus::Cancelled => crate::exit::GENERIC,
1005        _ => crate::exit::PARTIAL,
1006    }
1007}