Skip to main content

leviath_runtime/
host.rs

1//! The world host: the daemon-side wrapper that owns a single [`PipelineWorld`],
2//! maps stable **run ids** to ECS entities, and interleaves external **control
3//! operations** with driving the world - all on one task, so there is never any
4//! locking around the world.
5//!
6//! Clients (a control socket, the TUI, the CLI) don't hold entities - those are
7//! generational indices meaningful only inside the world. They address agents by
8//! run id. The host keeps the `run_id → Entity` map and turns each
9//! [`ControlOp`] into the corresponding [`PipelineWorld`] call, replying on the
10//! op's oneshot channel.
11//!
12//! The serve loop drives the world to quiescence, then parks until either an
13//! async result wakes it, a control op arrives, or shutdown is signalled -
14//! handling a control op and then re-driving to quiescence so its effect (a
15//! resume, a delivered message) is applied immediately.
16
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::time::Duration;
19
20use bevy_ecs::entity::Entity;
21use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
22use tokio::sync::{broadcast, oneshot};
23
24use crate::components::{
25    AgentMessage, AgentState, AgentStatus, AwaitingInteraction, ContextWindow, ParentRef,
26    SubAgentChildren, WaitReason,
27};
28use crate::interaction_hub::InteractionHub;
29use crate::persistence::{RunMetadata, TokenTotals};
30use crate::world::{LaneSnapshot, PipelineWorld};
31use leviath_core::interaction::{InteractionRequest, InteractionResponse};
32use serde::{Deserialize, Serialize};
33
34/// The parameters for spawning an agent into the world. The runtime doesn't know
35/// how to load blueprints or resolve tools - that policy lives in the
36/// [`Spawner`] the daemon installs - so this just carries the raw request.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
38pub struct SpawnArgs {
39    /// The run id to give the new agent (its directory / control key).
40    pub run_id: String,
41    /// Path to the agent manifest directory or bundle.
42    pub blueprint_path: String,
43    /// The task prompt. Seeded into the region keyed `task` (see
44    /// [`crate::context_setup::init_window_seeded`]); a matching `regions`
45    /// entry, if present, overrides it.
46    pub task: String,
47    /// Literal seed content for named caller-input regions, keyed by the
48    /// region's caller-input name. Merged over `task` at spawn. `#[serde(default)]`
49    /// keeps older requests (which never sent this) deserializing to an empty map.
50    #[serde(default)]
51    pub regions: HashMap<String, String>,
52    /// Optional model override (`provider/model` or `model`).
53    #[serde(default)]
54    pub model: Option<String>,
55    /// Working directory for tool execution.
56    pub workdir: String,
57    /// Custom key/value metadata from the request.
58    #[serde(default)]
59    pub metadata: HashMap<String, String>,
60    /// Webhook to POST on completion/error (surfaced in the run metadata).
61    #[serde(default)]
62    pub callback_url: Option<String>,
63    /// Optional shared secret for HMAC-SHA256 signing the webhook body.
64    #[serde(default)]
65    pub callback_secret: Option<String>,
66    /// Run this agent unattended (the `--yolo` launch override): approve every
67    /// tool call, waive the taint gate, and auto-answer the agent's own prompts
68    /// (`ask_user_*`, blueprint interaction points) rather than parking on the
69    /// interaction hub for a person who isn't there.
70    ///
71    /// An interaction point may opt out with `unattended = "ask"`, and then it
72    /// parks anyway: the point exists precisely because auto-approving it is
73    /// the wrong answer. Such a run waits for `[limits]
74    /// interaction_timeout_secs` (default 3600) rather than forever, but from
75    /// the outside an hour of `Waiting` is indistinguishable from a hang.
76    #[serde(default)]
77    pub yolo: bool,
78    /// Refuse this run's `seed = { command = ... }` regions (the
79    /// `--no-seed-commands` launch override). Command seeds execute at spawn,
80    /// before any approval prompt, so this is the per-run counterpart to the
81    /// `[security] allow_seed_commands` config switch.
82    #[serde(default)]
83    pub no_seed_commands: bool,
84    /// Tools to allow outright for this run (the `--allow` launch override).
85    #[serde(default)]
86    pub allow: Vec<String>,
87    /// Override the blueprint's max sub-agent tree depth.
88    #[serde(default)]
89    pub max_depth: Option<usize>,
90    /// The run id of this agent's parent, when it is a sub-agent / fan-out
91    /// worker. Persisted in the run metadata so observers (dashboard, `serve`
92    /// tree) can nest children under their parent. `None` for a top-level run.
93    #[serde(default)]
94    pub parent_run_id: Option<String>,
95}
96
97/// One row of a run listing ([`ControlRequest::List`]): a live run, its status,
98/// and enough context to judge whether that status is a problem.
99///
100/// [`ControlRequest::List`]: crate::control_socket::ControlRequest::List
101///
102/// `lev ps` used to be a run id and a status word, which is why issue #184
103/// happened: `waiting` on its own says nothing about whether a person is needed,
104/// and there was no way to tell a run that had moved a second ago from one that
105/// had been stopped for an hour. Everything here is read straight off the live
106/// world, so it is the daemon's own view, not a re-read of `meta.json`.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct RunListEntry {
109    /// The run id (`lev ps`'s first column, and what `lev kill` takes).
110    pub run_id: String,
111    /// The agent's live status.
112    pub status: AgentStatus,
113    /// Why the status is [`AgentStatus::Waiting`]; `None` for every other
114    /// status, and for a `Waiting` the host cannot attribute.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub wait_reason: Option<WaitReason>,
117    /// The stage the agent is in.
118    pub stage: String,
119    /// Zero-based index of that stage, when the agent tracks one.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub stage_index: Option<usize>,
122    /// How many stages the blueprint has.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub num_stages: Option<usize>,
125    /// Iterations completed in the current stage.
126    pub iteration: usize,
127    /// Cumulative tool calls across the run.
128    pub tool_calls: usize,
129    /// Unix seconds when this run last actually moved (see
130    /// [`PersistWatermark`](crate::pipeline::PersistWatermark)). Distinct from
131    /// `meta.json`'s `updated_at`, which also advances on a heartbeat and so
132    /// cannot be used to tell a working run from a wedged one.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub last_progress_at: Option<i64>,
135    /// Whether this run is unattended (`--yolo`). An unattended run should never
136    /// be sitting on a prompt; if it is, something dropped the flag.
137    #[serde(default)]
138    pub unattended: bool,
139    /// Whether this run finished having modified nothing, when its blueprint
140    /// gave it a way to. Only ever true for a run that has stopped.
141    ///
142    /// The flag itself is as old as issue #107, but nothing ever showed it: it
143    /// went into `meta.json` and was read back only on restart, so a run that
144    /// finished with no work to show for it looked exactly like one that
145    /// succeeded. Defaulted for the same reason as `unattended` - an older
146    /// daemon simply omits it.
147    #[serde(default)]
148    pub empty_output: bool,
149    /// How much of this run's `[read_paths]` its config granted at spawn.
150    /// `None` for a blueprint that declares none, which is nearly every agent.
151    ///
152    /// Worth a column of its own because an ungranted declaration is inert: the
153    /// run is up, looks healthy, and will be refused the reads its author
154    /// designed it around.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
157}
158
159/// Everything one [`ControlOp::List`] answers with: the live runs, the runs that
160/// finished recently enough to still be worth reporting, and the daemon's health.
161///
162/// A named struct rather than a tuple because the reply has now grown twice, and
163/// each time every caller had to be re-read positionally to find out which half
164/// was which.
165#[derive(Debug, Clone, Default, PartialEq)]
166pub struct RunListing {
167    /// One entry per run the daemon is hosting.
168    pub runs: Vec<RunListEntry>,
169    /// Runs the daemon has unloaded within its retention window, oldest first.
170    /// Kept apart from `runs` so a caller asking "what is running" still gets
171    /// only that.
172    pub finished: Vec<RunListEntry>,
173    /// How the daemon itself is doing.
174    pub health: DaemonHealth,
175}
176
177/// The daemon's own health, alongside the run listing.
178///
179/// A per-run view answers "what is this run doing"; this answers "is the daemon
180/// getting anywhere at all". They are different questions, and issue #191 was
181/// only visible in the second: every individual run looked fine, and the factory
182/// as a whole had not moved in hours.
183#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
184pub struct DaemonHealth {
185    /// Loaded agents by status.
186    pub agents: crate::world::AgentCounts,
187    /// Inference-pool occupancy, one entry per model actually used.
188    pub inference: Vec<crate::inference_pool::PoolOccupancy>,
189    /// Tool batches holding lane capacity and running.
190    pub tools_busy: usize,
191    /// Tool batches waiting for lane capacity.
192    pub tools_queued: usize,
193    /// Tool batches parked on an unbounded wait, holding no capacity.
194    pub tools_parked: usize,
195    /// The tool lane's concurrency cap, including any relief granted.
196    pub tools_workers: usize,
197    /// Consecutive safety re-drives that found a lane at capacity and no run
198    /// moving. Zero on a healthy daemon, and reset by any sign of progress.
199    pub dead_cycles: u32,
200    /// How many extra tool-lane permits the relief valve has handed out.
201    pub relief_granted: usize,
202    /// How often the daemon re-drives itself, so a client can turn
203    /// `dead_cycles` into wall-clock time.
204    pub redrive_secs: u64,
205    /// Providers currently out of service, and when each is probed again.
206    ///
207    /// Empty on a healthy daemon. `#[serde(default)]` so an older client still
208    /// parses a newer daemon's response (issue #201).
209    #[serde(default)]
210    pub providers_down: Vec<crate::pipeline::ProviderCircuitState>,
211}
212
213/// The daemon-installed function that turns [`SpawnArgs`] into a live agent:
214/// loads the blueprint, resolves stages/tools, spawns into the world, and
215/// returns the new entity (the host records the run-id mapping). Returns `Err`
216/// with a human-readable message on failure.
217pub type Spawner = Box<dyn FnMut(&mut PipelineWorld, &SpawnArgs) -> Result<Entity, String> + Send>;
218
219/// The daemon-installed function that pages a previously-unloaded run back into
220/// the world from its on-disk state: given a run id, it reloads the agent (its
221/// blueprint, tool state, context, stage) and returns the new entity, or `None`
222/// if there is no such resumable run on disk. Used for reload-on-demand - a
223/// control/sub-agent op targeting a run that isn't currently in memory pages it
224/// in first via the host's internal resolve-or-reload step. Installed with
225/// [`WorldHost::set_reloader`].
226pub type Reloader = Box<dyn FnMut(&mut PipelineWorld, &str) -> Option<Entity> + Send>;
227
228/// The daemon-installed last resort for cancelling a run the world cannot hold:
229/// given a run id, it forces that run's **on-disk** state to a terminal status
230/// and reports whether a run directory existed to act on.
231///
232/// This is what makes a cancel unconditional. [`Reloader`] declines whenever a
233/// run can't be rebuilt - its blueprint was moved or deleted, its metadata is
234/// unreadable, it died mid-spawn before any agent existed - and before this seam
235/// a cancel in that state replied `false` and wrote nothing, so `meta.json` kept
236/// claiming `running`/`starting` forever and the run could never be got rid of.
237/// The runtime has no notion of the on-disk layout, so the daemon supplies the
238/// writer. Installed with [`WorldHost::set_force_terminator`]; without one, a
239/// cancel that misses in the world simply misses (the prior behavior).
240pub type ForceTerminator = Box<dyn FnMut(&str) -> bool + Send>;
241
242/// The daemon-installed hook run just before a terminal agent's entity is
243/// despawned (reaped). It receives the world and the entity while both are still
244/// valid, so the daemon can release per-agent resources the runtime doesn't know
245/// about - tearing down the agent's sandbox and dropping its tool state.
246/// Installed with [`WorldHost::set_reaper`]; a no-op when none is set.
247pub type Reaper = Box<dyn FnMut(&mut PipelineWorld, Entity) + Send>;
248
249/// An async hook the host awaits *before* servicing a top-level `Spawn` control
250/// op, so the daemon can do async preparation the sync spawner can't - e.g.
251/// lazily connecting the blueprint's MCP servers into the shared pool so
252/// they're warm by the time [`Spawner`] reads them. The returned future is
253/// `'static` (it must clone anything it needs from the `SpawnArgs`). Installed
254/// with [`WorldHost::set_spawn_preprocessor`]; when none is set, spawns proceed
255/// straight to the spawner.
256pub type SpawnPreprocessor = Box<
257    dyn Fn(&SpawnArgs) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send,
258>;
259
260/// A world-access request from an agent's tool lane. The sub-agent tools
261/// (`spawn_agent`/`check_agent`/`send_to_agent`/`kill_agent`) need the world and
262/// the [`Spawner`], which only the host holds - the tool lane runs async, off the
263/// world. Each carries a oneshot reply, so the (sequential) tool lane blocks on
264/// the host applying it, mirroring the interaction hub.
265pub enum SubAgentOp {
266    /// Spawn a child agent from `args`, linked as a child of `parent_run_id`.
267    /// Rejected if the child would exceed `max_depth`. Reply is the child run id.
268    Spawn {
269        /// The child's spawn parameters (blueprint path, task, etc.). Boxed
270        /// because it is much larger than the other variants' payloads.
271        args: Box<SpawnArgs>,
272        /// The run id of the agent doing the spawning.
273        parent_run_id: String,
274        /// Maximum allowed sub-agent tree depth (root = 0).
275        max_depth: usize,
276        /// Reply: the child's run id, or an error message.
277        reply: oneshot::Sender<Result<String, String>>,
278    },
279    /// Report a run's current status (`None` if the host has no such live run).
280    Check {
281        /// The run to query.
282        run_id: String,
283        /// Reply: the run's status.
284        reply: oneshot::Sender<Option<AgentStatus>>,
285    },
286    /// Deliver a message into a running agent's inbox. Reply is whether a live
287    /// agent accepted it.
288    Send {
289        /// The target run.
290        run_id: String,
291        /// The run doing the sending. The target must be it or one of its
292        /// descendants - see `WorldHost::is_within_tree`.
293        caller_run_id: String,
294        /// The message body.
295        content: String,
296        /// Context region to deliver into (`None` = the "conversation"
297        /// default). The `send_to_agent` tool advertised this from the start
298        /// but the op had no field to carry it, so it was silently dropped.
299        target_region: Option<String>,
300        /// Reply: whether the message was accepted.
301        reply: oneshot::Sender<bool>,
302    },
303    /// Cancel a run and its whole sub-tree. Reply is whether any agent was found.
304    Kill {
305        /// The run to cancel (with its descendants).
306        run_id: String,
307        /// The run doing the cancelling. The target must be it or one of its
308        /// descendants - see `WorldHost::is_within_tree`.
309        caller_run_id: String,
310        /// Reply: whether anything was cancelled.
311        reply: oneshot::Sender<bool>,
312    },
313}
314
315/// A control operation addressed to the host, each carrying a oneshot channel the
316/// host replies on. Agents are addressed by run id.
317pub enum ControlOp {
318    /// Spawn a new agent. Reply is the run id on success, or an error message.
319    Spawn {
320        /// The spawn request. Boxed because it is much larger than the other
321        /// variants' payloads.
322        args: Box<SpawnArgs>,
323        /// Reply channel.
324        reply: oneshot::Sender<Result<String, String>>,
325    },
326    /// The status of a run, or `None` if there is no such run.
327    Status {
328        /// The run to query.
329        run_id: String,
330        /// Reply channel.
331        reply: oneshot::Sender<Option<AgentStatus>>,
332    },
333    /// Pause a run. Reply is `false` if there is no such (live) run.
334    Pause {
335        /// The run to pause.
336        run_id: String,
337        /// Reply channel.
338        reply: oneshot::Sender<bool>,
339    },
340    /// Resume a paused run. Reply is `false` if there is no such (live) run.
341    Resume {
342        /// The run to resume.
343        run_id: String,
344        /// Reply channel.
345        reply: oneshot::Sender<bool>,
346    },
347    /// Cancel a run. Reply is `false` if there is no such (live) run.
348    Cancel {
349        /// The run to cancel.
350        run_id: String,
351        /// Reply channel.
352        reply: oneshot::Sender<bool>,
353    },
354    /// List every known live run and its status, with the daemon's own health.
355    List {
356        /// Reply channel.
357        reply: oneshot::Sender<RunListing>,
358    },
359    /// Deliver a message to a running agent (by agent id). Reply is `false` if the
360    /// world's message channel is closed.
361    Message {
362        /// Target agent id.
363        agent_id: String,
364        /// Message body.
365        content: String,
366        /// Optional target region (defaults to the conversation region).
367        target_region: Option<String>,
368        /// Reply channel.
369        reply: oneshot::Sender<bool>,
370    },
371    /// List every open interaction awaiting an answer, as `(agent_id, request)`.
372    ListInteractions {
373        /// Reply channel.
374        reply: oneshot::Sender<Vec<(String, InteractionRequest)>>,
375    },
376    /// Answer an open interaction. Reply is `false` if no such request is open.
377    AnswerInteraction {
378        /// The answer (its `request_id` selects the interaction).
379        response: InteractionResponse,
380        /// Reply channel.
381        reply: oneshot::Sender<bool>,
382    },
383    /// Cancel an open interaction (its asker wakes with a neutral response).
384    /// Reply is `false` if no such request is open.
385    CancelInteraction {
386        /// The interaction id to cancel.
387        request_id: String,
388        /// Reply channel.
389        reply: oneshot::Sender<bool>,
390    },
391    /// Shut the daemon down: signal the world's shutdown so the serve loop
392    /// returns. Reply is sent (`true`) before the shutdown is triggered.
393    Shutdown {
394        /// Reply channel.
395        reply: oneshot::Sender<bool>,
396    },
397}
398
399/// A change in the world, broadcast to subscribers (the HTTP/WS gateway and
400/// in-process embedders) so they get pushed updates instead of polling. The
401/// coarse per-run variants (`Spawned`/`Status`/`Tokens`/`Context`/`Completed`)
402/// are emitted by the host's change-detection pass as it drives the world;
403/// `StageTransition`/`ToolCallStarted`/`ToolCallFinished`/`Log` are pushed at
404/// the source by pipeline systems through [`WorldEventSink`]. Streamed over the
405/// control transport via `ControlRequest::Subscribe`.
406///
407/// Marked non-exhaustive: new variants are additive, so consumers outside this
408/// crate must keep a catch-all arm.
409#[non_exhaustive]
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
411#[serde(tag = "event", rename_all = "snake_case")]
412pub enum WorldEvent {
413    /// A run first appeared in the world.
414    Spawned {
415        /// The run id.
416        run_id: String,
417        /// The agent id.
418        agent_id: String,
419        /// The blueprint / agent name.
420        blueprint: String,
421    },
422    /// A run's status, stage, iteration, or tool-call count changed.
423    Status {
424        /// The run id.
425        run_id: String,
426        /// The agent id.
427        agent_id: String,
428        /// Short status label (`active`, `waiting`, `complete`, …).
429        status: String,
430        /// The current stage name.
431        stage: String,
432        /// The current iteration.
433        iteration: usize,
434        /// Cumulative tool calls.
435        tool_calls: usize,
436        /// Whether the current stage accepts messages.
437        accepts_messages: bool,
438    },
439    /// A run's token totals changed.
440    Tokens {
441        /// The run id.
442        run_id: String,
443        /// The agent id.
444        agent_id: String,
445        /// Cumulative prompt tokens.
446        prompt_tokens: usize,
447        /// Cumulative completion tokens.
448        completion_tokens: usize,
449        /// Cumulative cached tokens.
450        cached_tokens: usize,
451        /// Cumulative cache-write tokens.
452        cache_write_tokens: usize,
453    },
454    /// A run's context-window token usage changed.
455    Context {
456        /// The run id.
457        run_id: String,
458        /// The agent id.
459        agent_id: String,
460        /// Current context tokens.
461        total_tokens: usize,
462        /// Max context tokens.
463        max_tokens: usize,
464    },
465    /// A run raised a new interaction awaiting an answer.
466    Interaction {
467        /// The run id.
468        run_id: String,
469        /// The agent id.
470        agent_id: String,
471        /// The interaction request.
472        request: InteractionRequest,
473    },
474    /// A run reached a terminal status.
475    Completed {
476        /// The run id.
477        run_id: String,
478        /// The agent id.
479        agent_id: String,
480        /// The terminal status label.
481        status: String,
482    },
483    /// A run moved from one stage to another. Emitted by the transition systems
484    /// at the moment the new stage is entered (the initial stage at spawn is
485    /// covered by [`WorldEvent::Spawned`], not by this).
486    StageTransition {
487        /// The run id.
488        run_id: String,
489        /// The agent id.
490        agent_id: String,
491        /// The stage being left.
492        from: String,
493        /// The stage being entered.
494        to: String,
495        /// How many times the destination stage has been entered, this entry
496        /// included.
497        iteration: usize,
498    },
499    /// A tool call was handed to the async tool lane for execution. Inline
500    /// calls (context tools, refusals, gate blocks) resolve without touching
501    /// the lane and don't produce this event.
502    ToolCallStarted {
503        /// The run id.
504        run_id: String,
505        /// The agent id.
506        agent_id: String,
507        /// The provider-assigned tool call id.
508        call_id: String,
509        /// The tool name.
510        tool: String,
511    },
512    /// A lane-executed tool call returned. Paired with
513    /// [`WorldEvent::ToolCallStarted`] by `call_id`.
514    ToolCallFinished {
515        /// The run id.
516        run_id: String,
517        /// The agent id.
518        agent_id: String,
519        /// The provider-assigned tool call id.
520        call_id: String,
521        /// The tool name.
522        tool: String,
523        /// Whether the call took effect (`false` for `[error]`/`[blocked]`/
524        /// `[unavailable]` results).
525        ok: bool,
526        /// The result, flattened to one line and truncated.
527        summary: String,
528    },
529    /// A run produced a per-agent log/output line (readable assistant output or
530    /// an operational `[Tokens: …]` / `[tool] …` / `[error] …` line).
531    Log {
532        /// The run id.
533        run_id: String,
534        /// The agent id.
535        agent_id: String,
536        /// The log line text.
537        line: String,
538    },
539}
540
541impl WorldEvent {
542    /// The run id this event belongs to. Every variant carries one; this saves
543    /// consumers an exhaustive match (which, with the enum non-exhaustive,
544    /// they could not write anyway).
545    pub fn run_id(&self) -> &str {
546        match self {
547            WorldEvent::Spawned { run_id, .. }
548            | WorldEvent::Status { run_id, .. }
549            | WorldEvent::Tokens { run_id, .. }
550            | WorldEvent::Context { run_id, .. }
551            | WorldEvent::Interaction { run_id, .. }
552            | WorldEvent::Completed { run_id, .. }
553            | WorldEvent::StageTransition { run_id, .. }
554            | WorldEvent::ToolCallStarted { run_id, .. }
555            | WorldEvent::ToolCallFinished { run_id, .. }
556            | WorldEvent::Log { run_id, .. } => run_id,
557        }
558    }
559}
560
561/// A world resource holding a clone of the host's [`WorldEvent`] broadcast
562/// sender, so ECS systems (e.g. the persistence drain) can push events - notably
563/// per-agent [`WorldEvent::Log`] lines - into the same stream the control
564/// transport serves. Absent in worlds that don't stream (test / `lev run`), where
565/// systems that depend on it become no-ops.
566// `Resource` moved from `bevy_ecs::system` to `bevy_ecs::resource` in 0.19.
567#[derive(bevy_ecs::resource::Resource, Clone)]
568pub struct WorldEventSink(pub broadcast::Sender<WorldEvent>);
569
570/// A short, stable status label for [`WorldEvent`]. Part of the daemon's wire
571/// contract (the REST WebSocket forwards it verbatim), so it comes from the one
572/// table on [`AgentStatus`] rather than a copy that could drift from it.
573fn status_str(status: &AgentStatus) -> &'static str {
574    status.label()
575}
576
577/// The last-emitted snapshot of an agent, for change detection.
578#[derive(Clone, Hash)]
579struct Emitted {
580    status: &'static str,
581    stage: String,
582    iteration: usize,
583    tool_calls: usize,
584    accepts_messages: bool,
585    prompt_tokens: usize,
586    completion_tokens: usize,
587    cached_tokens: usize,
588    cache_write_tokens: usize,
589    context_tokens: usize,
590    terminal: bool,
591}
592
593/// Owns the world and the run-id map; drives the world and services control ops.
594pub struct WorldHost {
595    world: PipelineWorld,
596    by_run_id: HashMap<String, Entity>,
597    interactions: InteractionHub,
598    spawner: Option<Spawner>,
599    spawn_preprocessor: Option<SpawnPreprocessor>,
600    reloader: Option<Reloader>,
601    force_terminator: Option<ForceTerminator>,
602    reaper: Option<Reaper>,
603    events: broadcast::Sender<WorldEvent>,
604    emitted: HashMap<String, Emitted>,
605    emitted_interactions: HashSet<String>,
606    /// Sub-agent world-access requests from tool lanes. The host holds a `tx`
607    /// clone so the receiver never closes (its `recv` never yields `None`).
608    subagent_tx: UnboundedSender<SubAgentOp>,
609    subagent_rx: UnboundedReceiver<SubAgentOp>,
610    /// How often [`Self::serve`] re-drives the world even though nothing woke
611    /// it. See [`Self::set_redrive_interval`].
612    redrive: Duration,
613    /// Consecutive re-drives that found the lanes full and nothing moved. See
614    /// [`Self::observe_redrive`].
615    dead_cycles: u32,
616    /// The progress fingerprint as of the previous re-drive, or `None` before
617    /// the first one.
618    last_progress: Option<u64>,
619    /// Extra tool-lane permits the relief valve has handed out over this
620    /// daemon's life.
621    relief_granted: usize,
622    /// Dead cycles the daemon tolerates before widening the tool lane. `0`
623    /// disables relief. See [`Self::set_dead_cycles_before_relief`].
624    dead_cycles_before_relief: u32,
625    /// Runs unloaded recently enough to still be worth reporting, oldest first,
626    /// each paired with the unix second it was unloaded. See
627    /// [`Self::record_finished`].
628    finished: VecDeque<(i64, RunListEntry)>,
629    /// How long an unloaded run stays in [`Self::finished`]. `0` keeps none.
630    /// See [`Self::set_finished_retention_secs`].
631    finished_retention_secs: u64,
632}
633
634/// How often the serve loop re-drives the world on its own.
635///
636/// The loop is event-driven, so a missed wake anywhere parks it indefinitely -
637/// the daemon looks alive while nothing progresses, which is what issue #189
638/// reported as hours of frozen agents. This bounds any such wedge to one
639/// interval instead of "until something unrelated happens", and gives the lane
640/// heartbeat a place to run.
641///
642/// Deliberately not configurable: it is a correctness backstop, not a tuning
643/// knob. A no-op re-drive is one tick over a handful of systems plus an event
644/// diff, so at this cadence it costs nothing measurable.
645const DEFAULT_REDRIVE_INTERVAL: Duration = Duration::from_secs(30);
646
647/// How many consecutive dead cycles trigger the tool-lane relief valve.
648///
649/// At the 30-second re-drive that is five minutes of a full lane going nowhere -
650/// long enough that ordinary backpressure never reaches it, short enough that a
651/// genuinely wedged daemon is not left overnight. Served from
652/// `[limits] dead_cycles_before_relief`; `0` disables relief.
653pub const DEFAULT_DEAD_CYCLES_BEFORE_RELIEF: u32 = 10;
654
655/// How long a run stays in the listing after the daemon unloads it.
656///
657/// A terminal agent is unloaded a pass or two after it finishes, and until now
658/// it vanished from the listing at that moment. A run that died on its first
659/// inference was therefore indistinguishable from one that had never been
660/// spawned, which is what left the scheduler in issue #205 with nothing to go on
661/// but a stopwatch: it could not tell a dead spawn from a slow one, so it
662/// reverted the work and spawned again, for forty minutes.
663///
664/// Five minutes covers several polls of any scheduler that checks in about once
665/// a minute, so a single missed or slow poll does not lose the evidence. It is
666/// also what the rest of the daemon already means by "long enough that a hiccup
667/// cannot cause it": the dashboard calls a run stale at 300 seconds, and
668/// [`DEFAULT_DEAD_CYCLES_BEFORE_RELIEF`] at the 30-second re-drive works out to
669/// the same five minutes.
670///
671/// Served from `[limits] finished_retention_secs`; `0` keeps nothing and
672/// restores the old behaviour.
673pub const DEFAULT_FINISHED_RETENTION_SECS: u64 = 300;
674
675/// How many unloaded runs [`WorldHost::finished`] holds before the oldest are
676/// dropped, whatever the retention window says.
677///
678/// Not configurable: it is a memory bound, not a tuning knob. A factory that
679/// finishes runs faster than this fills the window keeps the most recent ones,
680/// which are the ones anyone is still asking about. Set the window shorter to
681/// control how much the listing shows; this only stops it growing without end.
682const MAX_RETAINED_FINISHED: usize = 256;
683
684impl WorldHost {
685    /// Wrap a world with a fresh interaction hub.
686    pub fn new(world: PipelineWorld) -> Self {
687        Self::with_interactions(world, InteractionHub::new())
688    }
689
690    /// Wrap a world with a specific interaction hub - the daemon shares one hub
691    /// between the tool service's per-agent backends and this host.
692    pub fn with_interactions(mut world: PipelineWorld, interactions: InteractionHub) -> Self {
693        let (events, _) = broadcast::channel(1024);
694        // Let ECS systems (the persistence drain) push events - per-agent log
695        // lines - into the same stream the control transport serves.
696        world
697            .world_mut()
698            .insert_resource(WorldEventSink(events.clone()));
699        let (subagent_tx, subagent_rx) = tokio::sync::mpsc::unbounded_channel();
700        Self {
701            world,
702            by_run_id: HashMap::new(),
703            interactions,
704            spawner: None,
705            spawn_preprocessor: None,
706            reloader: None,
707            force_terminator: None,
708            reaper: None,
709            events,
710            emitted: HashMap::new(),
711            emitted_interactions: HashSet::new(),
712            subagent_tx,
713            subagent_rx,
714            redrive: DEFAULT_REDRIVE_INTERVAL,
715            dead_cycles: 0,
716            last_progress: None,
717            relief_granted: 0,
718            dead_cycles_before_relief: DEFAULT_DEAD_CYCLES_BEFORE_RELIEF,
719            finished: VecDeque::new(),
720            finished_retention_secs: DEFAULT_FINISHED_RETENTION_SECS,
721        }
722    }
723
724    /// Take stock once per safety re-drive: has anything moved, and are the lanes
725    /// full? Updates the dead-cycle count and reports.
726    ///
727    /// A *dead cycle* is a whole re-drive interval in which some lane was at
728    /// capacity with work queued behind it and no run observably moved. Both
729    /// halves matter. Pressure on its own is just a busy daemon. Stillness on its
730    /// own is an idle one, or one agent in a long inference with nobody waiting.
731    /// Together they are the shape issue #191 reported: work to do, no capacity to
732    /// do it with, and no sign of that ever changing.
733    fn observe_redrive(&mut self) {
734        let snapshot = self.world.lane_snapshot();
735        let progress = self.progress_fingerprint();
736        let went_nowhere = snapshot.is_under_pressure() && self.last_progress == Some(progress);
737        self.last_progress = Some(progress);
738        self.dead_cycles = match went_nowhere {
739            true => self.dead_cycles.saturating_add(1),
740            false => 0,
741        };
742        self.log_lane_pressure(&snapshot);
743        let relief = self.relieve_if_wedged(&snapshot);
744        self.observe_lanes(&snapshot, relief);
745    }
746
747    /// Widen the tool lane if the daemon has been going nowhere long enough, and
748    /// report how much capacity was added.
749    ///
750    /// Deliberately additive. The tempting reading of "force-reclaim stuck
751    /// slots" is to kill whatever is holding them, and that is the wrong move
752    /// here: a run parked on an `ask_user` is doing exactly what it should, and
753    /// an operator who mistook `waiting` for `stuck` and started killing healthy
754    /// runs is the story behind issue #184. Handing out more capacity unwedges a
755    /// jammed lane without having to be right about which run deserves to die.
756    ///
757    /// Only the tool lane is widened. A full inference pool is a deliberate cap
758    /// on requests in flight to a provider, and forcing extra ones past it would
759    /// trade a wedge for a rate limit.
760    ///
761    /// Capped at one extra lane's worth over the daemon's life. If that is not
762    /// enough, the problem is not capacity and more of it will not help.
763    fn relieve_if_wedged(&mut self, snapshot: &LaneSnapshot) -> usize {
764        let threshold = self.dead_cycles_before_relief;
765        if threshold == 0 || self.dead_cycles < threshold || !snapshot.tools_saturated {
766            return 0;
767        }
768        // The snapshot's width already includes everything granted so far, so
769        // back it out to get the lane's configured width - the budget.
770        let configured = snapshot.tools_workers.saturating_sub(self.relief_granted);
771        let remaining = configured.saturating_sub(self.relief_granted);
772        let granted = self
773            .world
774            .relieve_tool_lane(remaining.min(snapshot.tools_queued));
775        self.relief_granted += granted;
776        tracing::error!(
777            dead_cycles = self.dead_cycles,
778            granted,
779            relief_granted = self.relief_granted,
780            tools_queued = snapshot.tools_queued,
781            tools_parked = snapshot.tools_parked,
782            "the tool lane has not drained in {} cycles; widening it by {granted}",
783            self.dead_cycles
784        );
785        // Give the widened lane a fresh interval to show whether it helped,
786        // rather than granting again on the very next re-drive.
787        self.dead_cycles = 0;
788        granted
789    }
790
791    /// How long a run stays in the listing after the daemon unloads it. `0`
792    /// keeps none, which is how the listing behaved before issue #205. Served
793    /// from `[limits] finished_retention_secs`.
794    pub fn set_finished_retention_secs(&mut self, secs: u64) {
795        self.finished_retention_secs = secs;
796    }
797
798    /// Keep `entry` in the listing as a run that finished at `at`.
799    ///
800    /// One row per run: an id already held is replaced rather than duplicated,
801    /// so however often a run is unloaded it is reported once.
802    ///
803    /// `last_progress_at` is filled in from `at` when the run never persisted a
804    /// snapshot. That is not a guess. A run that died on its first inference has
805    /// no watermark to read, and the listing would show its age as `-` - which
806    /// is the one thing an operator or a scheduler most wants to know about a
807    /// run that failed instantly. For a run being unloaded, the unload is the
808    /// last thing that happened to it.
809    fn record_finished(&mut self, mut entry: RunListEntry, at: i64) {
810        if self.finished_retention_secs == 0 {
811            return;
812        }
813        entry.last_progress_at.get_or_insert(at);
814        self.finished
815            .retain(|(_, held)| held.run_id != entry.run_id);
816        self.finished.push_back((at, entry));
817        while self.finished.len() > MAX_RETAINED_FINISHED {
818            self.finished.pop_front();
819        }
820    }
821
822    /// Drop unloaded runs that have outlived the retention window.
823    ///
824    /// `now` is passed in rather than read here so a test can age the buffer
825    /// without sleeping through the window, the same reason `lev ps`'s
826    /// `format_runs` takes it. Called once per [`Self::emit_events`], which the
827    /// serve loop runs before it handles any control op, so a listing never has
828    /// to prune on the way out.
829    fn prune_finished(&mut self, now: i64) {
830        let window = self.finished_retention_secs as i64;
831        while let Some(&(at, _)) = self.finished.front() {
832            if now.saturating_sub(at) <= window {
833                break;
834            }
835            self.finished.pop_front();
836        }
837    }
838
839    /// How many dead cycles the daemon tolerates before widening the tool lane.
840    /// `0` disables relief; detection and reporting are unaffected. Served from
841    /// `[limits] dead_cycles_before_relief`.
842    pub fn set_dead_cycles_before_relief(&mut self, cycles: u32) {
843        self.dead_cycles_before_relief = cycles;
844    }
845
846    /// Hand one daemon-wide health sample to the telemetry sink.
847    ///
848    /// `relief` is the capacity granted on this sample, which is a per-sample
849    /// figure rather than a running total: the sink accumulates it.
850    fn observe_lanes(&self, snapshot: &LaneSnapshot, relief: usize) {
851        // Every `PipelineWorld::new` installs the sink resource (a no-op one
852        // unless a host replaced it), so this is a hard invariant rather than a
853        // branch - the same reasoning as `set_exact_token_counting`.
854        self.world
855            .world()
856            .resource::<crate::telemetry::Telemetry>()
857            .0
858            .observe_lanes(leviath_core::telemetry::LaneHealth {
859                agents_active: snapshot.agents.active,
860                agents_waiting: snapshot.agents.waiting,
861                tools_busy: snapshot.tools_busy,
862                tools_queued: snapshot.tools_queued,
863                tools_parked: snapshot.tools_parked,
864                tools_workers: snapshot.tools_workers,
865                dead_cycles: self.dead_cycles,
866                relief_granted: relief,
867            });
868        // Sampled on the same tick, and unconditionally: a collector needs the
869        // empty sample to see that a provider came *back*, not just that it
870        // went away (issue #201).
871        let down: Vec<leviath_core::telemetry::ProviderHealth> = self
872            .world
873            .open_circuits()
874            .into_iter()
875            .map(|c| leviath_core::telemetry::ProviderHealth {
876                provider: c.provider,
877                reason: c.reason.label().to_string(),
878                consecutive_failures: c.consecutive_failures,
879                retry_in_secs: c.retry_in_secs,
880            })
881            .collect();
882        self.world
883            .world()
884            .resource::<crate::telemetry::Telemetry>()
885            .0
886            .observe_providers(&down);
887    }
888
889    /// The daemon's own health: lane occupancy plus the dead-cycle count.
890    ///
891    /// Served alongside every run listing, because "is this run stuck" and "is
892    /// the daemon stuck" are answered by different numbers and an operator
893    /// looking at one wants the other in the same breath.
894    pub fn health(&self) -> DaemonHealth {
895        let snapshot = self.world.lane_snapshot();
896        DaemonHealth {
897            agents: snapshot.agents,
898            inference: snapshot.inference,
899            tools_busy: snapshot.tools_busy,
900            tools_queued: snapshot.tools_queued,
901            tools_parked: snapshot.tools_parked,
902            tools_workers: snapshot.tools_workers,
903            dead_cycles: self.dead_cycles,
904            relief_granted: self.relief_granted,
905            redrive_secs: self.redrive.as_secs(),
906            providers_down: self.world.open_circuits(),
907        }
908    }
909
910    /// A number that changes exactly when some run observably moves.
911    ///
912    /// Derived from the per-run snapshots `emit_events` already keeps to decide
913    /// what to broadcast, so an unchanged fingerprint means "nothing happened
914    /// that anyone watching would have been told about" - not merely "no event
915    /// was sent", which would also be true of a daemon nobody is subscribed to.
916    ///
917    /// Summed rather than fed through one hasher because a `HashMap` has no
918    /// iteration order to depend on. Every field it covers is either monotonic or
919    /// hashed, so two different worlds colliding takes a deliberate effort.
920    fn progress_fingerprint(&self) -> u64 {
921        use std::hash::{Hash, Hasher};
922        let mut total = self.emitted.len() as u64;
923        for entry in &self.emitted {
924            let mut hasher = std::collections::hash_map::DefaultHasher::new();
925            entry.hash(&mut hasher);
926            total = total.wrapping_add(hasher.finish());
927        }
928        total
929    }
930
931    /// Report what the lanes are holding.
932    ///
933    /// The daemon otherwise logs nothing per tick, by design - observation goes
934    /// through the telemetry sink. But a wedged daemon emits no telemetry either,
935    /// precisely because nothing is happening, so "frozen for hours" left no
936    /// trace at all (issue #189). This is the one periodic line that can answer
937    /// "is anything running, and what is it queued behind?".
938    ///
939    /// Quiet by default: `warn` once the daemon has been going nowhere, `info`
940    /// while a lane is merely at capacity, `debug` otherwise, so an idle daemon
941    /// says nothing above `debug`.
942    fn log_lane_pressure(&self, snapshot: &LaneSnapshot) {
943        let agents = snapshot.agents.to_string();
944        let inference = snapshot.inference_summary();
945        if self.dead_cycles > 0 {
946            tracing::warn!(
947                dead_cycles = self.dead_cycles,
948                agents = %agents,
949                inference = %inference,
950                tools_busy = snapshot.tools_busy,
951                tools_workers = snapshot.tools_workers,
952                tools_queued = snapshot.tools_queued,
953                tools_parked = snapshot.tools_parked,
954                "no progress while the lanes are full"
955            );
956        } else if snapshot.is_under_pressure() {
957            tracing::info!(
958                agents = %agents,
959                inference = %inference,
960                tools_busy = snapshot.tools_busy,
961                tools_workers = snapshot.tools_workers,
962                tools_queued = snapshot.tools_queued,
963                tools_parked = snapshot.tools_parked,
964                "lane heartbeat: at capacity with work queued"
965            );
966        } else {
967            tracing::debug!(
968                agents = %agents,
969                inference = %inference,
970                tools_busy = snapshot.tools_busy,
971                tools_workers = snapshot.tools_workers,
972                tools_queued = snapshot.tools_queued,
973                tools_parked = snapshot.tools_parked,
974                "lane heartbeat"
975            );
976        }
977    }
978
979    /// Override how often [`Self::serve`] re-drives the world with no wake.
980    ///
981    /// Exists so tests don't have to wait out the 30-second default; the daemon
982    /// uses it as-is.
983    pub fn set_redrive_interval(&mut self, every: Duration) {
984        self.redrive = every;
985    }
986
987    /// A sender for [`SubAgentOp`]s. The daemon hands a clone to each agent's tool
988    /// state so the sub-agent tools can reach the world through the host.
989    pub fn subagent_sender(&self) -> UnboundedSender<SubAgentOp> {
990        self.subagent_tx.clone()
991    }
992
993    /// Subscribe to [`WorldEvent`]s. The HTTP/WS gateway uses this (via the
994    /// control transport's `Subscribe`) to push updates instead of polling.
995    pub fn subscribe(&self) -> broadcast::Receiver<WorldEvent> {
996        self.events.subscribe()
997    }
998
999    /// The world-event sender, handed to the control transport so a `Subscribe`
1000    /// connection can stream events.
1001    pub fn event_sender(&self) -> broadcast::Sender<WorldEvent> {
1002        self.events.clone()
1003    }
1004
1005    /// Diff every registered run against its last-emitted snapshot and broadcast
1006    /// what changed (status/tokens/context/completion) plus any new interaction.
1007    /// Called after each drive to quiescence, so subscribers see every change.
1008    fn emit_events(&mut self) {
1009        self.adopt_unregistered_runs();
1010        let pairs: Vec<(String, Entity)> = self
1011            .by_run_id
1012            .iter()
1013            .map(|(k, &v)| (k.clone(), v))
1014            .collect();
1015        // Terminal agents to unload from memory this pass (their disk state is
1016        // preserved and still viewable). Collected during the loop, reaped after.
1017        // The listing row travels with each one: it is built here, while the
1018        // entity is untouched, rather than in the reap loop below, where the
1019        // daemon's reap hook has already had the world and is free to have taken
1020        // the components it reads.
1021        let mut to_reap: Vec<(String, Entity, RunListEntry)> = Vec::new();
1022        let now = chrono::Utc::now().timestamp();
1023        for (run_id, entity) in pairs {
1024            let Some(state) = self.world.world().get::<AgentState>(entity) else {
1025                continue; // reaped between registration and now
1026            };
1027            let agent_id = state.agent_id.clone();
1028            let status = status_str(&state.status);
1029            let terminal = matches!(
1030                state.status,
1031                AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
1032            );
1033            let cur = {
1034                let totals = self
1035                    .world
1036                    .world()
1037                    .get::<TokenTotals>(entity)
1038                    .copied()
1039                    .unwrap_or_default();
1040                let (context_tokens, _) = self
1041                    .world
1042                    .world()
1043                    .get::<ContextWindow>(entity)
1044                    .map(|w| (w.current_tokens, w.max_tokens))
1045                    .unwrap_or((0, 0));
1046                Emitted {
1047                    status,
1048                    stage: state.current_stage.clone(),
1049                    iteration: state.iteration,
1050                    tool_calls: totals.tool_calls,
1051                    accepts_messages: state.accepts_messages,
1052                    prompt_tokens: totals.prompt_tokens,
1053                    completion_tokens: totals.completion_tokens,
1054                    cached_tokens: totals.cached_tokens,
1055                    cache_write_tokens: totals.cache_write_tokens,
1056                    context_tokens,
1057                    terminal,
1058                }
1059            };
1060            let max_tokens = self
1061                .world
1062                .world()
1063                .get::<ContextWindow>(entity)
1064                .map(|w| w.max_tokens)
1065                .unwrap_or(0);
1066            let prev = self.emitted.get(&run_id).cloned();
1067
1068            if prev.is_none() {
1069                let blueprint = self
1070                    .world
1071                    .world()
1072                    .get::<RunMetadata>(entity)
1073                    .map(|m| m.agent_name.clone())
1074                    .unwrap_or_default();
1075                let _ = self.events.send(WorldEvent::Spawned {
1076                    run_id: run_id.clone(),
1077                    agent_id: agent_id.clone(),
1078                    blueprint,
1079                });
1080            }
1081
1082            let status_key = |e: &Emitted| {
1083                (
1084                    e.status,
1085                    e.stage.clone(),
1086                    e.iteration,
1087                    e.tool_calls,
1088                    e.accepts_messages,
1089                )
1090            };
1091            if prev.as_ref().map(status_key) != Some(status_key(&cur)) {
1092                let _ = self.events.send(WorldEvent::Status {
1093                    run_id: run_id.clone(),
1094                    agent_id: agent_id.clone(),
1095                    status: status.to_string(),
1096                    stage: cur.stage.clone(),
1097                    iteration: cur.iteration,
1098                    tool_calls: cur.tool_calls,
1099                    accepts_messages: cur.accepts_messages,
1100                });
1101            }
1102
1103            let token_key = |e: &Emitted| {
1104                (
1105                    e.prompt_tokens,
1106                    e.completion_tokens,
1107                    e.cached_tokens,
1108                    e.cache_write_tokens,
1109                )
1110            };
1111            if prev.as_ref().map(token_key) != Some(token_key(&cur)) {
1112                let _ = self.events.send(WorldEvent::Tokens {
1113                    run_id: run_id.clone(),
1114                    agent_id: agent_id.clone(),
1115                    prompt_tokens: cur.prompt_tokens,
1116                    completion_tokens: cur.completion_tokens,
1117                    cached_tokens: cur.cached_tokens,
1118                    cache_write_tokens: cur.cache_write_tokens,
1119                });
1120            }
1121
1122            if prev.as_ref().map(|e| e.context_tokens) != Some(cur.context_tokens) {
1123                let _ = self.events.send(WorldEvent::Context {
1124                    run_id: run_id.clone(),
1125                    agent_id: agent_id.clone(),
1126                    total_tokens: cur.context_tokens,
1127                    max_tokens,
1128                });
1129            }
1130
1131            let was_terminal = prev.as_ref().map(|e| e.terminal) == Some(true);
1132            if cur.terminal && !was_terminal {
1133                let _ = self.events.send(WorldEvent::Completed {
1134                    run_id: run_id.clone(),
1135                    agent_id: agent_id.clone(),
1136                    status: status.to_string(),
1137                });
1138            }
1139            // Unload a terminal agent once its terminal state has been emitted (a
1140            // prior pass already saw it terminal, so the event went out and the
1141            // persistence lane captured it) and no live parent still needs it.
1142            if cur.terminal && was_terminal && self.no_live_parent(entity) {
1143                let entry = self.entry_for(&run_id, entity, state);
1144                to_reap.push((run_id.clone(), entity, entry));
1145            }
1146            // NOTE: non-terminal `Waiting` agents are intentionally NOT unloaded.
1147            // Every `Waiting` state carries a live, unpersisted continuation - a
1148            // blocked `ask` future (`AwaitingInteraction`), running fan-out workers
1149            // (`FanOutWaiting`), or pending children (`WaitingForChildren`) - so
1150            // flushing one to disk and paging it back cannot resume it (in-flight
1151            // interactions aren't persisted; the blocked future is gone). Only
1152            // terminal agents, whose full state is on disk, are safe to reap.
1153
1154            self.emitted.insert(run_id, cur);
1155        }
1156
1157        // Reap: run the daemon's reap hook (sandbox teardown + tool-state drop)
1158        // while the entity is still valid, then despawn it and erase its host-map
1159        // entries. Iterating a snapshot of `by_run_id` above means removing here
1160        // is safe. The reaper is moved out for the loop to avoid borrowing `self`
1161        // twice, then restored.
1162        let mut reaper = self.reaper.take();
1163        for (run_id, entity, entry) in to_reap {
1164            if let Some(reaper) = reaper.as_mut() {
1165                reaper(&mut self.world, entity);
1166            }
1167            self.world.world_mut().despawn(entity);
1168            self.by_run_id.remove(&run_id);
1169            self.emitted.remove(&run_id);
1170            // The run leaves memory but not the listing: for a while yet it can
1171            // still say how it ended, which is the whole of issue #205.
1172            self.record_finished(entry, now);
1173        }
1174        self.reaper = reaper;
1175        self.prune_finished(now);
1176
1177        for (agent_id, request) in self.interactions.pending() {
1178            if self.emitted_interactions.insert(request.id.clone()) {
1179                let _ = self.events.send(WorldEvent::Interaction {
1180                    run_id: agent_id.clone(),
1181                    agent_id,
1182                    request,
1183                });
1184            }
1185        }
1186    }
1187
1188    /// Register any agent that exists in the world but is missing from the run-id
1189    /// map, so the host's view is the world's view.
1190    ///
1191    /// Not every agent arrives through a `Spawn` control op: fan-out workers are
1192    /// built straight into the world by the fan-out spawner, which has no handle
1193    /// on the host to register them. An unregistered agent is invisible to `list`
1194    /// (so `lev ps` never showed a worker), never reaped (its sandbox and tool
1195    /// state leak), and - worst - un-cancellable, because a cancel by its run id
1196    /// misses the map, falls through to the reloader, and pages a **second** live
1197    /// entity in from that run's on-disk state while the original keeps running.
1198    /// Adopting them here is idempotent and keeps a stale mapping from winning:
1199    /// a registered id whose entity has been despawned is re-pointed.
1200    fn adopt_unregistered_runs(&mut self) {
1201        let live: Vec<(String, Entity)> = self
1202            .world
1203            .world_mut()
1204            .query::<(Entity, &RunMetadata)>()
1205            .iter(self.world.world())
1206            .map(|(entity, md)| (md.run_id.clone(), entity))
1207            .collect();
1208        for (run_id, entity) in live {
1209            if self.live_entity(&run_id) != Some(entity) {
1210                self.by_run_id.insert(run_id, entity);
1211            }
1212        }
1213    }
1214
1215    /// Whether a terminal agent is safe to unload: it has no **live** parent that
1216    /// might still be waiting on it. True for a root (no `ParentRef`), or when its
1217    /// parent has been despawned or is itself terminal; false while a non-terminal
1218    /// parent could still be gating on this child.
1219    fn no_live_parent(&self, entity: Entity) -> bool {
1220        let world = self.world.world();
1221        match world.get::<crate::components::ParentRef>(entity) {
1222            None => true,
1223            Some(parent_ref) => match world.get::<AgentState>(parent_ref.parent_entity) {
1224                None => true,
1225                Some(state) => matches!(
1226                    state.status,
1227                    AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
1228                ),
1229            },
1230        }
1231    }
1232
1233    /// Install the spawner used to service `Spawn` control ops. Without one, a
1234    /// `Spawn` op replies with an error.
1235    pub fn set_spawner(&mut self, spawner: Spawner) {
1236        self.spawner = Some(spawner);
1237    }
1238
1239    /// Install the async hook awaited before each top-level `Spawn` (see
1240    /// [`SpawnPreprocessor`]).
1241    pub fn set_spawn_preprocessor(&mut self, pp: SpawnPreprocessor) {
1242        self.spawn_preprocessor = Some(pp);
1243    }
1244
1245    /// Install the reloader used to page an unloaded run back in on demand.
1246    /// Without one, an op targeting a run that isn't in memory just misses.
1247    pub fn set_reloader(&mut self, reloader: Reloader) {
1248        self.reloader = Some(reloader);
1249    }
1250
1251    /// Install the [`ForceTerminator`] used to terminate a run on disk when the
1252    /// world cannot hold it. Without one, a cancel that misses in the world and
1253    /// can't be reloaded just misses.
1254    pub fn set_force_terminator(&mut self, force_terminator: ForceTerminator) {
1255        self.force_terminator = Some(force_terminator);
1256    }
1257
1258    /// Install the reap hook run just before each terminal agent is despawned,
1259    /// so the daemon can tear down that agent's sandbox and drop its tool state.
1260    /// Without one, reaping just despawns the entity (the prior behavior).
1261    pub fn set_reaper(&mut self, reaper: Reaper) {
1262        self.reaper = Some(reaper);
1263    }
1264
1265    /// Resolve a run id to a live entity, paging it in from disk if it has been
1266    /// unloaded (and a reloader is installed). Returns `None` if the run is
1267    /// neither live nor resumable from disk. Newly-reloaded runs are registered.
1268    fn resolve_or_reload(&mut self, run_id: &str) -> Option<Entity> {
1269        if let Some(entity) = self.live_entity(run_id) {
1270            return Some(entity);
1271        }
1272        let entity = (self.reloader.as_mut()?)(&mut self.world, run_id)?;
1273        self.by_run_id.insert(run_id.to_string(), entity);
1274        Some(entity)
1275    }
1276
1277    /// A clone of the interaction hub, for building per-agent backends.
1278    pub fn interactions(&self) -> InteractionHub {
1279        self.interactions.clone()
1280    }
1281
1282    /// Mutable access to the underlying world (for the spawner to add agents).
1283    pub fn world_mut(&mut self) -> &mut PipelineWorld {
1284        &mut self.world
1285    }
1286
1287    /// Record the run-id → entity mapping for a freshly-spawned agent.
1288    pub fn register(&mut self, run_id: impl Into<String>, entity: Entity) {
1289        self.by_run_id.insert(run_id.into(), entity);
1290    }
1291
1292    /// Resolve a run id to a **live** entity (one that still exists in the world).
1293    fn live_entity(&self, run_id: &str) -> Option<Entity> {
1294        let entity = *self.by_run_id.get(run_id)?;
1295        self.world.world().get::<AgentState>(entity).map(|_| entity)
1296    }
1297
1298    /// Service one [`SubAgentOp`] from a tool lane, replying on its oneshot.
1299    fn handle_subagent(&mut self, op: SubAgentOp) {
1300        match op {
1301            SubAgentOp::Spawn {
1302                args,
1303                parent_run_id,
1304                max_depth,
1305                reply,
1306            } => {
1307                let _ = reply.send(self.spawn_child(*args, &parent_run_id, max_depth));
1308            }
1309            SubAgentOp::Check { run_id, reply } => {
1310                let status = self
1311                    .live_entity(&run_id)
1312                    .and_then(|e| self.world.agent_status(e));
1313                let _ = reply.send(status);
1314            }
1315            SubAgentOp::Send {
1316                run_id,
1317                caller_run_id,
1318                content,
1319                target_region,
1320                reply,
1321            } => {
1322                if !self.is_within_tree(&run_id, &caller_run_id) {
1323                    let _ = reply.send(false);
1324                    return;
1325                }
1326                // Page the target in if it was unloaded, so delivery finds it.
1327                self.resolve_or_reload(&run_id);
1328                let ok = self
1329                    .world
1330                    .send_message(AgentMessage {
1331                        agent_id: run_id,
1332                        content,
1333                        target_region,
1334                    })
1335                    .is_ok();
1336                let _ = reply.send(ok);
1337            }
1338            SubAgentOp::Kill {
1339                run_id,
1340                caller_run_id,
1341                reply,
1342            } => {
1343                let within = self.is_within_tree(&run_id, &caller_run_id);
1344                let _ = reply.send(within && self.cancel_tree(&run_id));
1345            }
1346        }
1347    }
1348
1349    /// Spawn a child agent under `parent_run_id`, linking `ParentRef` /
1350    /// `SubAgentChildren` and registering its run id. `Err` if the parent is not
1351    /// live, the depth limit is reached, or the spawner rejects it.
1352    fn spawn_child(
1353        &mut self,
1354        mut args: SpawnArgs,
1355        parent_run_id: &str,
1356        max_depth: usize,
1357    ) -> Result<String, String> {
1358        // Record the parentage so the child's run metadata nests it in the tree.
1359        args.parent_run_id = Some(parent_run_id.to_string());
1360        let parent = self
1361            .live_entity(parent_run_id)
1362            .ok_or_else(|| format!("parent run '{parent_run_id}' is not live"))?;
1363        let parent_depth = self
1364            .world
1365            .world()
1366            .get::<ParentRef>(parent)
1367            .map_or(0, |p| p.depth);
1368        let child_depth = parent_depth + 1;
1369        if child_depth > max_depth {
1370            return Err(format!(
1371                "sub-agent depth limit ({max_depth}) reached; not spawning deeper"
1372            ));
1373        }
1374        let run_id = args.run_id.clone();
1375        let child = match self.spawner.as_mut() {
1376            Some(spawner) => spawner(&mut self.world, &args)?,
1377            None => return Err("this daemon cannot spawn agents".to_string()),
1378        };
1379        let world = self.world.world_mut();
1380        world.entity_mut(child).insert(ParentRef {
1381            parent_entity: parent,
1382            parent_agent_id: parent_run_id.to_string(),
1383            depth: child_depth,
1384        });
1385        match world.get_mut::<SubAgentChildren>(parent) {
1386            Some(mut kids) => kids.children.push(child),
1387            None => {
1388                world.entity_mut(parent).insert(SubAgentChildren {
1389                    children: vec![child],
1390                    max_child_depth: max_depth,
1391                });
1392            }
1393        }
1394        // Record the child's run-id on the parent's serializable state so the
1395        // tree is persisted (and restart can rebuild `SubAgentChildren`). A
1396        // spawning parent always carries `AgentState`.
1397        world
1398            .get_mut::<crate::components::AgentState>(parent)
1399            .expect("a spawning parent always has AgentState")
1400            .spawned_children_ids
1401            .push(run_id.clone());
1402        // Seed the child's context from the parent per any declared blueprint
1403        // context transform (planner→coder region mapping, etc.).
1404        crate::context_transform::apply_context_transforms(world, parent, child);
1405        self.by_run_id.insert(run_id.clone(), child);
1406        Ok(run_id)
1407    }
1408
1409    /// Cancel a run and every descendant, paging the root in from disk first if it
1410    /// had been unloaded. Returns whether the run was found in the world.
1411    ///
1412    /// Cancelling only the root would leave its sub-agents and fan-out workers
1413    /// running - they are independent agents the schedule keeps driving, so they
1414    /// would carry on spending tokens with no parent to report to. Each cancelled
1415    /// agent's open interactions are closed too, so nothing is left blocked on a
1416    /// prompt for a run that is going away.
1417    /// Whether `run_id` is `ancestor` itself or one of its descendants.
1418    ///
1419    /// `send_to_agent` and `kill_agent` took any run id at all. Nothing tied the
1420    /// target to the caller, so an agent could cancel an unrelated run, inject
1421    /// text into its context, or - worst - hand it data: a message is added to
1422    /// the target as `Public` regardless of the sender's taint, so an agent
1423    /// holding `Private` context whose own outbound tools were gated could pass
1424    /// it to a sibling whose tools were not. That is a laundering channel
1425    /// straight through the middle of taint tracking.
1426    ///
1427    /// A downward walk from the caller, the same shape [`cancel_tree`] uses:
1428    /// parentage is recorded as `SubAgentChildren`, so "is it mine" is "is it in
1429    /// my subtree".
1430    ///
1431    /// [`cancel_tree`]: Self::cancel_tree
1432    fn is_within_tree(&mut self, run_id: &str, ancestor: &str) -> bool {
1433        if run_id == ancestor {
1434            return true;
1435        }
1436        // Both ends as entities: the host already maps run ids to them, and
1437        // comparing entities avoids re-reading an id component per node.
1438        let (Some(target), Some(root)) = (
1439            self.resolve_or_reload(run_id),
1440            self.resolve_or_reload(ancestor),
1441        ) else {
1442            return false;
1443        };
1444        let mut stack = vec![root];
1445        while let Some(e) = stack.pop() {
1446            if e == target {
1447                return true;
1448            }
1449            if let Some(kids) = self.world.world().get::<SubAgentChildren>(e) {
1450                stack.extend(kids.children.iter().copied());
1451            }
1452        }
1453        false
1454    }
1455
1456    fn cancel_tree(&mut self, run_id: &str) -> bool {
1457        let Some(root) = self.resolve_or_reload(run_id) else {
1458            return false;
1459        };
1460        // Collect the subtree (parent before children), then cancel each.
1461        let mut subtree = Vec::new();
1462        let mut stack = vec![root];
1463        while let Some(e) = stack.pop() {
1464            subtree.push(e);
1465            if let Some(kids) = self.world.world().get::<SubAgentChildren>(e) {
1466                stack.extend(kids.children.iter().copied());
1467            }
1468        }
1469        let mut cancelled = false;
1470        for e in subtree {
1471            // Read the agent id before cancelling - the entity stays valid until
1472            // it is reaped, but reading first keeps this independent of that.
1473            let agent_id = self
1474                .world
1475                .world()
1476                .get::<AgentState>(e)
1477                .map(|s| s.agent_id.clone());
1478            cancelled |= self.world.cancel(e);
1479            if let Some(agent_id) = agent_id {
1480                self.interactions.cancel_for_agent(&agent_id);
1481                // The hub is keyed by agent id but the emitted-interaction set is
1482                // keyed by request id, so drop the ids that are no longer pending.
1483                let still_open: HashSet<String> = self
1484                    .interactions
1485                    .pending()
1486                    .into_iter()
1487                    .map(|(_, req)| req.id)
1488                    .collect();
1489                self.emitted_interactions
1490                    .retain(|id| still_open.contains(id));
1491            }
1492        }
1493        cancelled
1494    }
1495
1496    /// Why `entity` is [`AgentStatus::Waiting`], read off the markers the engine
1497    /// already maintains. `None` when the agent is not waiting, or when it is
1498    /// waiting for a reason nothing has claimed.
1499    ///
1500    /// Order matters. A taint-gate block and a stage checkpoint each open a hub
1501    /// request of their own, so both also carry [`AwaitingInteraction`]; asking
1502    /// the specific markers first is what keeps them from all reporting as a
1503    /// generic prompt.
1504    pub fn wait_reason(&self, entity: Entity) -> Option<WaitReason> {
1505        let world = self.world.world();
1506        let state = world.get::<AgentState>(entity)?;
1507        if state.status != AgentStatus::Waiting {
1508            return None;
1509        }
1510        if world
1511            .get::<crate::gate_prompt::AwaitingGatePrompt>(entity)
1512            .is_some()
1513        {
1514            return Some(WaitReason::TaintGate);
1515        }
1516        if world
1517            .get::<crate::interaction_points::AwaitingInteractionPoint>(entity)
1518            .is_some()
1519        {
1520            return Some(WaitReason::InteractionPoint);
1521        }
1522        if let Some(fanout) = world.get::<crate::fanout::FanOutWaiting>(entity) {
1523            return Some(WaitReason::FanOutWorkers {
1524                outstanding: fanout.outstanding(),
1525            });
1526        }
1527        if world
1528            .get::<crate::pipeline::WaitingForChildren>(entity)
1529            .is_some()
1530        {
1531            let outstanding = world
1532                .get::<SubAgentChildren>(entity)
1533                .map(|c| {
1534                    c.children
1535                        .iter()
1536                        .filter(|&&child| {
1537                            world
1538                                .get::<AgentState>(child)
1539                                .is_some_and(|s| !crate::pipeline::is_terminal_status(&s.status))
1540                        })
1541                        .count()
1542                })
1543                .unwrap_or(0);
1544            return Some(WaitReason::Children { outstanding });
1545        }
1546        if world.get::<AwaitingInteraction>(entity).is_some() {
1547            // The hub is keyed by agent id, and one agent can only be parked on
1548            // one prompt at a time, so the first match is the one blocking it.
1549            let kind = self
1550                .interactions
1551                .pending()
1552                .into_iter()
1553                .find(|(agent_id, _)| *agent_id == state.agent_id)
1554                .map(|(_, req)| req.kind);
1555            return Some(match kind {
1556                Some(leviath_core::interaction::InteractionKind::ToolApproval) => {
1557                    WaitReason::ToolApproval
1558                }
1559                _ => WaitReason::UserPrompt,
1560            });
1561        }
1562        None
1563    }
1564
1565    /// One listing row for a run, read off the live world.
1566    ///
1567    /// Shared by [`Self::list`] and by the unload path in [`Self::emit_events`],
1568    /// so a run's last row is built exactly the way every row before it was.
1569    /// Takes the state rather than looking it up because the unload path already
1570    /// holds one, and a `None` it could never return would be a branch nothing
1571    /// can reach.
1572    fn entry_for(&self, run_id: &str, entity: Entity, state: &AgentState) -> RunListEntry {
1573        let world = self.world.world();
1574        let metadata = world.get::<RunMetadata>(entity);
1575        RunListEntry {
1576            run_id: run_id.to_string(),
1577            status: state.status.clone(),
1578            wait_reason: self.wait_reason(entity),
1579            stage: state.current_stage.clone(),
1580            stage_index: world
1581                .get::<crate::pipeline::StageCursor>(entity)
1582                .map(|c| c.index),
1583            num_stages: metadata.map(|m| m.num_stages),
1584            iteration: state.iteration,
1585            tool_calls: world.get::<TokenTotals>(entity).map_or(0, |t| t.tool_calls),
1586            last_progress_at: world
1587                .get::<crate::pipeline::PersistWatermark>(entity)
1588                .and_then(|w| w.last_progress_at()),
1589            unattended: metadata.is_some_and(|m| m.unattended),
1590            empty_output: world
1591                .get::<crate::persistence::RunOutcomeFlags>(entity)
1592                .is_some_and(|f| crate::persistence::is_empty_output(&state.status, &f.0)),
1593            read_paths: metadata.and_then(|m| m.read_paths),
1594        }
1595    }
1596
1597    /// List every known live run with the context an operator needs to read its
1598    /// status: why it is waiting, where it is, and when it last moved.
1599    fn list(&self) -> Vec<RunListEntry> {
1600        let world = self.world.world();
1601        self.by_run_id
1602            .iter()
1603            .filter_map(|(run_id, &entity)| {
1604                let state = world.get::<AgentState>(entity)?;
1605                Some(self.entry_for(run_id, entity, state))
1606            })
1607            .collect()
1608    }
1609
1610    /// The runs unloaded recently enough to still be reported, oldest first.
1611    ///
1612    /// Kept apart from [`Self::list`] rather than folded into it because
1613    /// "running now" and "finished a moment ago" are different questions, and
1614    /// two callers already depend on the first one: `lev daemon status` counts
1615    /// the hosted agents, and the dashboard uses the listing to decide which
1616    /// runs the daemon still holds.
1617    fn finished(&self) -> Vec<RunListEntry> {
1618        self.finished
1619            .iter()
1620            .map(|(_, entry)| entry.clone())
1621            .collect()
1622    }
1623
1624    /// Apply one control op and reply on its channel. A dropped reply receiver is
1625    /// harmless (the requester went away).
1626    pub fn handle(&mut self, op: ControlOp) {
1627        match op {
1628            ControlOp::Spawn { args, reply } => {
1629                let result = match self.spawner.as_mut() {
1630                    // Spawning runs outside the pipeline schedule, so it isn't
1631                    // covered by `run_isolated`'s panic guard: a panic while
1632                    // parsing a blueprint or building a sandbox would otherwise
1633                    // unwind the whole serve task and take the daemon with it.
1634                    // As with `run_isolated`, the world may be left holding a
1635                    // partially-built entity - the run just never registers.
1636                    Some(spawner) => {
1637                        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1638                            spawner(&mut self.world, &args)
1639                        })) {
1640                            Ok(Ok(entity)) => {
1641                                self.by_run_id.insert(args.run_id.clone(), entity);
1642                                Ok(args.run_id.clone())
1643                            }
1644                            Ok(Err(e)) => Err(e),
1645                            Err(_) => Err("agent spawn panicked".to_string()),
1646                        }
1647                    }
1648                    None => Err("this daemon cannot spawn agents".to_string()),
1649                };
1650                // A failed spawn must leave a trace daemon-side: the error goes
1651                // back over the socket to a client that may have already exited,
1652                // and nothing is written to disk, so without this log line the
1653                // failure is invisible (issue #107).
1654                if let Err(error) = &result {
1655                    tracing::error!(
1656                        run_id = %args.run_id,
1657                        blueprint = %args.blueprint_path,
1658                        workdir = %args.workdir,
1659                        error = %error,
1660                        "agent spawn failed"
1661                    );
1662                }
1663                let _ = reply.send(result);
1664            }
1665            ControlOp::Status { run_id, reply } => {
1666                // A run the daemon has unloaded still has an answer for a
1667                // while, so a caller that asks a moment too late learns how the
1668                // run ended instead of being told there is no such run.
1669                let status = self
1670                    .live_entity(&run_id)
1671                    .and_then(|e| self.world.agent_status(e))
1672                    .or_else(|| {
1673                        self.finished
1674                            .iter()
1675                            .find(|(_, e)| e.run_id == run_id)
1676                            .map(|(_, e)| e.status.clone())
1677                    });
1678                let _ = reply.send(status);
1679            }
1680            ControlOp::Pause { run_id, reply } => {
1681                let ok = self
1682                    .resolve_or_reload(&run_id)
1683                    .is_some_and(|e| self.world.pause(e));
1684                let _ = reply.send(ok);
1685            }
1686            ControlOp::Resume { run_id, reply } => {
1687                let ok = self
1688                    .resolve_or_reload(&run_id)
1689                    .is_some_and(|e| self.world.resume(e));
1690                let _ = reply.send(ok);
1691            }
1692            ControlOp::Cancel { run_id, reply } => {
1693                // Cancel is unconditional: it either takes effect in the world
1694                // (root plus every descendant) or, when the run can't be held
1695                // there at all, is forced onto its on-disk state. It reports
1696                // `false` only when there is genuinely no such run anywhere -
1697                // otherwise a run whose blueprint had moved stayed `running` on
1698                // disk forever with no way to get rid of it.
1699                let ok = self.cancel_tree(&run_id)
1700                    || self
1701                        .force_terminator
1702                        .as_mut()
1703                        .is_some_and(|terminate| terminate(&run_id));
1704                let _ = reply.send(ok);
1705            }
1706            ControlOp::List { reply } => {
1707                let _ = reply.send(RunListing {
1708                    runs: self.list(),
1709                    finished: self.finished(),
1710                    health: self.health(),
1711                });
1712            }
1713            ControlOp::Message {
1714                agent_id,
1715                content,
1716                target_region,
1717                reply,
1718            } => {
1719                // Page the target in if it was unloaded, so delivery finds it.
1720                self.resolve_or_reload(&agent_id);
1721                let ok = self
1722                    .world
1723                    .send_message(AgentMessage {
1724                        agent_id,
1725                        content,
1726                        target_region,
1727                    })
1728                    .is_ok();
1729                let _ = reply.send(ok);
1730            }
1731            ControlOp::ListInteractions { reply } => {
1732                let _ = reply.send(self.interactions.pending());
1733            }
1734            ControlOp::AnswerInteraction { response, reply } => {
1735                let _ = reply.send(self.interactions.answer(response));
1736            }
1737            ControlOp::CancelInteraction { request_id, reply } => {
1738                let _ = reply.send(self.interactions.cancel(&request_id));
1739            }
1740            ControlOp::Shutdown { reply } => {
1741                // Reply first (best effort), then trigger the world's shutdown so
1742                // the serve loop's next `select!` returns.
1743                let _ = reply.send(true);
1744                self.world.shutdown();
1745            }
1746        }
1747    }
1748
1749    /// Flush all queued persistence and stop the hosted world, guaranteeing every
1750    /// dirty agent's final snapshot reaches disk (see
1751    /// [`PipelineWorld::flush_and_stop`]). Invoked automatically when [`Self::serve`]
1752    /// returns; also exposed directly for callers that drive the world themselves.
1753    pub async fn flush_and_stop(&mut self) {
1754        self.world.flush_and_stop().await;
1755    }
1756
1757    /// Run the host: drive the world to quiescence, then park until an async
1758    /// result wakes it, a control op arrives, or shutdown is signalled. Returns
1759    /// when shutdown fires or the control channel closes - and before returning,
1760    /// **flushes all queued persistence to disk** ([`Self::flush_and_stop`]) so a
1761    /// clean daemon shutdown never loses a dirty agent's final snapshot.
1762    pub async fn serve(&mut self, mut control_rx: UnboundedReceiver<ControlOp>) {
1763        let wake = self.world.wake_handle();
1764        let shutdown = self.world.shutdown_handle();
1765        // `interval_at` rather than `interval`: the latter's first tick is
1766        // immediately ready, which would spin one pointless pass at startup.
1767        // `Delay` keeps a slow drive from queueing a burst of catch-up ticks.
1768        let mut redrive =
1769            tokio::time::interval_at(tokio::time::Instant::now() + self.redrive, self.redrive);
1770        redrive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1771        'serve: loop {
1772            self.world.run_to_fixed_point();
1773            self.emit_events();
1774            tokio::select! {
1775                _ = wake.notified() => {}
1776                _ = shutdown.notified() => break 'serve,
1777                // The backstop. Everything else here is edge-triggered, so a
1778                // release or completion that forgets to wake us would otherwise
1779                // park the daemon indefinitely with work left to do. Re-driving
1780                // on a timer bounds that to one interval, and is where the lane
1781                // heartbeat reports what the loop is actually waiting on.
1782                _ = redrive.tick() => self.observe_redrive(),
1783                op = control_rx.recv() => {
1784                    match op {
1785                        // Await the spawn preprocessor (e.g. lazy MCP connect) before
1786                        // the sync spawner runs, so the pool is warm. The returned
1787                        // future is `'static`, so no borrow of `self`/`op` outlives it.
1788                        Some(op) => {
1789                            let pre = match &op {
1790                                ControlOp::Spawn { args, .. } => {
1791                                    self.spawn_preprocessor.as_ref().map(|pp| pp(args))
1792                                }
1793                                _ => None,
1794                            };
1795                            if let Some(fut) = pre {
1796                                fut.await;
1797                            }
1798                            self.handle(op);
1799                        }
1800                        None => break 'serve, // all control senders dropped
1801                    }
1802                }
1803                // The host holds a `subagent_tx`, so this only yields `Some`.
1804                Some(sub) = self.subagent_rx.recv() => {
1805                    // Warm a spawning sub-agent's MCP servers first, same as a
1806                    // top-level Spawn (both run in this async loop).
1807                    let pre = match &sub {
1808                        SubAgentOp::Spawn { args, .. } => {
1809                            self.spawn_preprocessor.as_ref().map(|pp| pp(args))
1810                        }
1811                        _ => None,
1812                    };
1813                    if let Some(fut) = pre {
1814                        fut.await;
1815                    }
1816                    self.handle_subagent(sub);
1817                }
1818            }
1819        }
1820        // Shutting down: drain the persistence lane before the world is dropped.
1821        self.flush_and_stop().await;
1822    }
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827    use super::*;
1828    use crate::dynamic_interaction::InteractionBackend;
1829    use crate::inference_pool::InferencePoolConfig;
1830    use crate::pipeline::{
1831        AgentBlueprint, ReadyToInfer, StageCursor, StageInference, StageInferences, StageProgress,
1832        StageSetup, StageSetups, ToolService, VisitCounts, WaitingForChildren,
1833    };
1834    use crate::tool_bridge::BoxedToolExec;
1835    use leviath_core::{Region, RegionKind};
1836    use leviath_providers::{
1837        FinishReason, InferenceRequest, InferenceResponse, ModelCapabilities, Provider,
1838        ProviderError, TokenUsage,
1839    };
1840    use std::sync::Arc;
1841    use std::sync::Mutex;
1842    use tokio::runtime::Handle;
1843    use tokio::sync::mpsc;
1844
1845    struct Script {
1846        responses: Mutex<std::collections::VecDeque<InferenceResponse>>,
1847    }
1848    #[async_trait::async_trait]
1849    impl Provider for Script {
1850        async fn infer(
1851            &self,
1852            _req: InferenceRequest,
1853        ) -> leviath_providers::Result<InferenceResponse> {
1854            self.responses
1855                .lock()
1856                .unwrap()
1857                .pop_front()
1858                .ok_or_else(|| ProviderError::Other("exhausted".to_string()))
1859        }
1860        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1861            1
1862        }
1863        fn max_context_tokens(&self, _m: &str) -> usize {
1864            100_000
1865        }
1866        fn name(&self) -> &str {
1867            "script"
1868        }
1869        fn capabilities(&self, _m: &str) -> ModelCapabilities {
1870            ModelCapabilities::default()
1871        }
1872    }
1873
1874    struct NoTools;
1875    impl ToolService for NoTools {
1876        fn exec_for(
1877            &self,
1878            _e: Entity,
1879            calls: Vec<leviath_providers::ToolCall>,
1880            _progress: crate::pipeline::ToolProgress,
1881        ) -> BoxedToolExec {
1882            Box::new(move || {
1883                Box::pin(async move { calls.into_iter().map(|c| (c.id, String::new())).collect() })
1884            })
1885        }
1886    }
1887
1888    fn text(content: &str) -> InferenceResponse {
1889        InferenceResponse {
1890            content: content.to_string(),
1891            tool_calls: vec![],
1892            tokens_used: TokenUsage {
1893                prompt_tokens: 1,
1894                completion_tokens: 1,
1895                total_tokens: 2,
1896                cached_tokens: 0,
1897                cache_write_tokens: 0,
1898            },
1899            finish_reason: FinishReason::Complete,
1900        }
1901    }
1902
1903    fn host_with(responses: Vec<InferenceResponse>) -> WorldHost {
1904        let mut registry = crate::providers::ProviderRegistry::new();
1905        registry.register(
1906            "script".to_string(),
1907            Arc::new(Script {
1908                responses: Mutex::new(responses.into_iter().collect()),
1909            }),
1910        );
1911        let world = PipelineWorld::new(
1912            registry,
1913            Arc::new(NoTools),
1914            InferencePoolConfig::new(),
1915            1,
1916            None,
1917            Handle::current(),
1918        );
1919        WorldHost::new(world)
1920    }
1921
1922    fn blueprint() -> leviath_core::Blueprint {
1923        let layout = leviath_core::layout::ContextLayout::new(
1924            vec![leviath_core::layout::RegionDefinition::new(
1925                "conversation".to_string(),
1926                RegionKind::Clearable,
1927                10_000,
1928            )],
1929            12_000,
1930        );
1931        let s = leviath_core::Stage::new(
1932            "s".to_string(),
1933            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1934        );
1935        leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
1936    }
1937
1938    fn window() -> crate::components::ContextWindow {
1939        let mut w = crate::components::ContextWindow::new(10_000);
1940        w.add_region(Region::new(
1941            "conversation".to_string(),
1942            RegionKind::Clearable,
1943            10_000,
1944        ));
1945        w
1946    }
1947
1948    fn agent_state(agent_id: &str) -> AgentState {
1949        AgentState {
1950            agent_id: agent_id.to_string(),
1951            current_stage: "s".to_string(),
1952            iteration: 0,
1953            status: AgentStatus::Active,
1954            spawned_children_ids: vec![],
1955            pending_wait: None,
1956            accepts_messages: true,
1957        }
1958    }
1959
1960    fn si() -> StageInference {
1961        StageInference {
1962            provider_name: "script".to_string(),
1963            model: "m".to_string(),
1964            tools: vec![],
1965            tool_filter: None,
1966            fallbacks: Vec::new(),
1967        }
1968    }
1969
1970    fn setup() -> StageSetup {
1971        StageSetup {
1972            inference_config: crate::components::InferenceConfig {
1973                temperature: None,
1974                max_output_tokens: None,
1975                extra_params: Default::default(),
1976                batch_tool_hint: false,
1977                shell_hint: false,
1978                request_timeout_secs: None,
1979            },
1980            routing: None,
1981            accepts_messages: true,
1982            context_layout: None,
1983            system_prompt: None,
1984        }
1985    }
1986
1987    /// Spawn a simple agent into the host and register it under `run_id`.
1988    fn spawn(host: &mut WorldHost, run_id: &str, agent_id: &str) -> Entity {
1989        let e = host.world_mut().spawn_agent((
1990            AgentBlueprint(blueprint()),
1991            StageCursor { index: 0 },
1992            agent_state(agent_id),
1993            crate::components::MessageInbox::default(),
1994            StageProgress::default(),
1995            StageInferences(vec![si()]),
1996            StageSetups(vec![setup()]),
1997            VisitCounts::default(),
1998            window(),
1999            si(),
2000            setup().inference_config,
2001            ReadyToInfer,
2002        ));
2003        host.register(run_id, e);
2004        e
2005    }
2006
2007    /// A [`ForceTerminator`] that records each run id it was asked to terminate
2008    /// and reports success for everything but `"never-existed"`. Shared by the
2009    /// tests that expect it to fire and the ones that expect it not to, so its
2010    /// body is exercised rather than existing only to go unused.
2011    fn recording_terminator(seen: Arc<Mutex<Vec<String>>>) -> ForceTerminator {
2012        Box::new(move |run_id| {
2013            seen.lock().unwrap().push(run_id.to_string());
2014            run_id != "never-existed"
2015        })
2016    }
2017
2018    /// A [`Reloader`] that pages any run id in as a fresh agent.
2019    fn paging_reloader() -> Reloader {
2020        Box::new(|world, run_id| Some(world.spawn_agent((agent_state(run_id),))))
2021    }
2022
2023    async fn ask<T>(host: &mut WorldHost, make: impl FnOnce(oneshot::Sender<T>) -> ControlOp) -> T {
2024        let (tx, rx) = oneshot::channel();
2025        host.handle(make(tx));
2026        rx.await.unwrap()
2027    }
2028
2029    /// A provider whose call never returns while `hang` is set - the stalled
2030    /// request that holds its pool permit until something cancels the job.
2031    ///
2032    /// The non-hanging arm is not decoration: a body that only ever diverges has
2033    /// no reachable return, so the answering path is what keeps this honest (and
2034    /// measurable) - the same shape `inference_bridge`'s `Scripted` uses.
2035    struct Hangs {
2036        hang: bool,
2037    }
2038    #[async_trait::async_trait]
2039    impl Provider for Hangs {
2040        async fn infer(
2041            &self,
2042            _req: InferenceRequest,
2043        ) -> leviath_providers::Result<InferenceResponse> {
2044            if self.hang {
2045                std::future::pending().await
2046            } else {
2047                Err(ProviderError::Other("not hanging".to_string()))
2048            }
2049        }
2050        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
2051            1
2052        }
2053        fn max_context_tokens(&self, _m: &str) -> usize {
2054            100_000
2055        }
2056        fn name(&self) -> &str {
2057            "hangs"
2058        }
2059        fn capabilities(&self, _m: &str) -> ModelCapabilities {
2060            ModelCapabilities::default()
2061        }
2062    }
2063
2064    /// The stalling provider's own surface. `infer` never returns by design, so
2065    /// it is reached under a timeout; the rest are plain accessors the dispatch
2066    /// path reads.
2067    #[tokio::test]
2068    async fn the_hanging_provider_answers_everything_except_a_hanging_infer() {
2069        fn request() -> InferenceRequest {
2070            InferenceRequest {
2071                system: vec![],
2072                messages: vec![],
2073                model: "m".to_string(),
2074                max_tokens: 1,
2075                temperature: 0.0,
2076                tools: vec![],
2077                extra: serde_json::Value::Null,
2078                request_timeout_secs: None,
2079            }
2080        }
2081        let p = Hangs { hang: true };
2082        assert_eq!(p.name(), "hangs");
2083        assert_eq!(p.count_tokens("t", "m").await, 1);
2084        assert_eq!(p.max_context_tokens("m"), 100_000);
2085        let _ = p.capabilities("m");
2086        assert!(
2087            tokio::time::timeout(std::time::Duration::from_millis(20), p.infer(request()))
2088                .await
2089                .is_err(),
2090            "hanging: the whole point is that the call never lands"
2091        );
2092        // ...and the answering arm, so the call has a reachable way out.
2093        assert!(Hangs { hang: false }.infer(request()).await.is_err());
2094    }
2095
2096    /// A host whose only provider hangs, with model `m` capped at `limit`
2097    /// concurrent inferences - so the second agent to want a slot is starved
2098    /// until the first one gives its permit back.
2099    fn host_with_full_pool(limit: usize) -> WorldHost {
2100        let mut registry = crate::providers::ProviderRegistry::new();
2101        registry.register("script".to_string(), Arc::new(Hangs { hang: true }));
2102        let mut pools = InferencePoolConfig::new();
2103        pools.set_limit("m", limit);
2104        WorldHost::new(PipelineWorld::new(
2105            registry,
2106            Arc::new(NoTools),
2107            pools,
2108            1,
2109            None,
2110            Handle::current(),
2111        ))
2112    }
2113
2114    /// How long [`serve_until_inferring`] waits at each park before calling the loop
2115    /// wedged. A wake that is coming lands as soon as the freeing task is
2116    /// polled, so this is only ever spent proving the *absence* of one.
2117    const PARK: std::time::Duration = std::time::Duration::from_millis(250);
2118
2119    /// Drive `host` exactly the way [`WorldHost::serve`] does - run the world to
2120    /// quiescence, then park until something wakes it - and report whether
2121    /// `entity` got dispatched within `rounds` parks. `false` means the loop
2122    /// parked with nothing left to wake it, which is the daemon wedging.
2123    ///
2124    /// Takes the entity rather than a predicate closure on purpose: a generic
2125    /// parameter would give each call site its own instantiation, and no single
2126    /// one of them exercises both the "it happened" and "we wedged" exits.
2127    async fn serve_until_inferring(
2128        host: &mut WorldHost,
2129        rounds: usize,
2130        park: std::time::Duration,
2131        entity: Entity,
2132    ) -> bool {
2133        let wake = host.world_mut().wake_handle();
2134        for _ in 0..rounds {
2135            host.world_mut().run_to_fixed_point();
2136            if is_inferring(host, entity) {
2137                return true;
2138            }
2139            if tokio::time::timeout(park, wake.notified()).await.is_err() {
2140                break; // parked with no wake pending - nothing will re-drive us
2141            }
2142        }
2143        false
2144    }
2145
2146    /// Whether `entity` has been handed a pool permit and dispatched.
2147    fn is_inferring(host: &mut WorldHost, entity: Entity) -> bool {
2148        host.world_mut()
2149            .world()
2150            .get::<crate::pipeline::AwaitingInference>(entity)
2151            .is_some()
2152    }
2153
2154    /// Regression for #189 ("slots=0 for hours, in_progress frozen").
2155    ///
2156    /// Releasing an inference permit has to wake the tick loop, because
2157    /// `dispatch_inference` leaves a slot-starved agent `ReadyToInfer` to be
2158    /// "retried on a later tick" - and the loop is event-driven, so a later tick
2159    /// only happens when something wakes it. A cancelled job frees its permit
2160    /// from a detached task, *after* the tick chain has already run to
2161    /// quiescence over the cancel. If that release is silent, the freed slot is
2162    /// invisible: capacity sits idle while every agent queued behind it stays
2163    /// parked, for as long as it takes some unrelated event to wake the loop.
2164    #[tokio::test]
2165    async fn releasing_a_cancelled_runs_permit_wakes_the_starved_agent_behind_it() {
2166        let mut host = host_with_full_pool(1);
2167
2168        // Dispatch the holder first and on its own, so which agent wins the
2169        // single permit is decided here rather than by the parallel dispatch.
2170        let holder = spawn(&mut host, "run-a", "agent-a");
2171        host.world_mut().run_to_fixed_point();
2172        assert!(is_inferring(&mut host, holder), "the holder takes the slot");
2173
2174        let starved = spawn(&mut host, "run-b", "agent-b");
2175        host.world_mut().run_to_fixed_point();
2176        assert!(
2177            !is_inferring(&mut host, starved),
2178            "the second agent is starved on the full pool"
2179        );
2180        // And it stays starved for as long as the slot is genuinely held - the
2181        // cap is real, not an artifact of the wake. Several rounds, because the
2182        // first park consumes the wake the spawn itself stored; the loop has to
2183        // reach a park with nothing pending before "wedged" means anything.
2184        assert!(
2185            !serve_until_inferring(&mut host, 3, PARK, starved).await,
2186            "no slot, no dispatch"
2187        );
2188
2189        // Cancel the holder the way `lev cancel` does. The tick chain aborts its
2190        // in-flight work; the permit itself comes back later, on the job's task.
2191        assert!(
2192            ask(&mut host, |reply| ControlOp::Cancel {
2193                run_id: "run-a".to_string(),
2194                reply,
2195            })
2196            .await
2197        );
2198
2199        assert!(
2200            serve_until_inferring(&mut host, 8, PARK, starved).await,
2201            "the freed slot must wake the loop so the starved agent can take it; \
2202             without that wake the daemon parks with capacity it cannot see"
2203        );
2204    }
2205
2206    /// The backstop, on its own terms: `serve` must make progress from a timer
2207    /// alone, with nothing ever waking it. Whatever else goes silent - a release
2208    /// that forgets to notify, a lane that reports nothing - the daemon still
2209    /// re-examines the world instead of parking indefinitely.
2210    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2211    async fn serve_redrives_the_world_on_its_own_timer_with_no_wake() {
2212        use std::sync::atomic::{AtomicUsize, Ordering};
2213
2214        // Counts ticks from inside the schedule, so the assertion is about the
2215        // loop actually running - not about some state that a single startup
2216        // pass could equally have produced.
2217        static TICKS: AtomicUsize = AtomicUsize::new(0);
2218        TICKS.store(0, Ordering::SeqCst);
2219        fn count_ticks() {
2220            TICKS.fetch_add(1, Ordering::SeqCst);
2221        }
2222
2223        let mut host = host_with(vec![]);
2224        host.world_mut().add_test_system(count_ticks);
2225        host.set_redrive_interval(std::time::Duration::from_millis(20));
2226        let shutdown = host.world_mut().shutdown_handle();
2227
2228        let (op_tx, op_rx) = mpsc::unbounded_channel();
2229        let handle = tokio::spawn(async move {
2230            host.serve(op_rx).await;
2231        });
2232
2233        // Nothing is ever sent on `op_tx`, nothing is spawned, and no wake is
2234        // signalled: an empty world quiesces immediately, so every tick past the
2235        // first handful is one the timer produced.
2236        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
2237        let ticks = TICKS.load(Ordering::SeqCst);
2238        shutdown.notify_one();
2239        drop(op_tx);
2240        handle.await.unwrap();
2241
2242        assert!(
2243            ticks > 3,
2244            "the timer must keep driving the world with nothing waking it; saw {ticks} ticks"
2245        );
2246    }
2247
2248    /// A two-stage linear blueprint (`one` -> `two`), for the stage-boundary
2249    /// tests. No transitions declared: `resolve_transition_sync` falls through to
2250    /// the next stage in order, which is the ordinary case.
2251    fn two_stage_blueprint() -> leviath_core::Blueprint {
2252        let layout = leviath_core::layout::ContextLayout::new(
2253            vec![leviath_core::layout::RegionDefinition::new(
2254                "conversation".to_string(),
2255                RegionKind::Clearable,
2256                10_000,
2257            )],
2258            12_000,
2259        );
2260        let model =
2261            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string());
2262        // Both stages end by running out of iterations, which is how a stage that
2263        // keeps calling tools finishes. That boundary is the one the driver used
2264        // to miss: `enforce_max_iterations` and `resolve_transition` both run in
2265        // the same tick, so the agent leaves `ReadyToInfer` and comes back to it
2266        // with every marker count exactly as it was.
2267        let mut one = leviath_core::Stage::new("one".to_string(), model.clone());
2268        one.max_iterations = Some(1);
2269        let mut two = leviath_core::Stage::new("two".to_string(), model);
2270        two.max_iterations = Some(1);
2271        let stages = vec![one, two];
2272        leviath_core::Blueprint::new("t".to_string(), "d".to_string(), stages, layout)
2273    }
2274
2275    /// Spawn an agent that starts at stage `one` of [`two_stage_blueprint`].
2276    fn spawn_two_stage(host: &mut WorldHost, run_id: &str, agent_id: &str) -> Entity {
2277        let mut state = agent_state(agent_id);
2278        state.current_stage = "one".to_string();
2279        let e = host.world_mut().spawn_agent((
2280            AgentBlueprint(two_stage_blueprint()),
2281            StageCursor { index: 0 },
2282            state,
2283            crate::components::MessageInbox::default(),
2284            StageProgress::default(),
2285            StageInferences(vec![si(), si()]),
2286            StageSetups(vec![setup(), setup()]),
2287            VisitCounts::default(),
2288            window(),
2289            si(),
2290            setup().inference_config,
2291            ReadyToInfer,
2292        ));
2293        host.register(run_id, e);
2294        e
2295    }
2296
2297    /// A response that asks for one tool call - what a working stage returns
2298    /// right up to the iteration that ends it.
2299    fn tool_call(id: &str) -> InferenceResponse {
2300        InferenceResponse {
2301            tool_calls: vec![leviath_providers::ToolCall {
2302                id: id.to_string(),
2303                name: "noop".to_string(),
2304                arguments: serde_json::Value::Null,
2305                thought_signature: None,
2306            }],
2307            ..text("working")
2308        }
2309    }
2310
2311    /// Regression for #197 ("entering the next stage waits for the re-drive
2312    /// tick").
2313    ///
2314    /// `serve` is event-driven; its 30s re-drive is a correctness backstop for a
2315    /// wake that never came, not the mechanism ordinary work runs on. A stage
2316    /// boundary that only makes progress on the timer puts up to 30s of dead time
2317    /// on every transition - a five-stage run loses minutes to nothing.
2318    ///
2319    /// The re-drive is set out of reach here, so the run can only finish through
2320    /// the wake path.
2321    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2322    async fn a_stage_boundary_is_crossed_without_waiting_for_the_redrive() {
2323        let mut host = host_with(vec![tool_call("c1"), tool_call("c2")]);
2324        host.set_redrive_interval(std::time::Duration::from_secs(3600));
2325        spawn_two_stage(&mut host, "run-a", "agent-a");
2326
2327        let mut events = host.subscribe();
2328        let shutdown = host.world_mut().shutdown_handle();
2329        let (op_tx, op_rx) = mpsc::unbounded_channel();
2330        let handle = tokio::spawn(async move { host.serve(op_rx).await });
2331
2332        // Watch the event stream rather than the world: `serve` owns the host for
2333        // as long as it runs. Everything before `Completed` (spawn, status,
2334        // tokens) streams past on the way.
2335        let completed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
2336            loop {
2337                let event = events
2338                    .recv()
2339                    .await
2340                    .expect("the event stream must outlive the run");
2341                if let WorldEvent::Completed { status, .. } = event {
2342                    break status;
2343                }
2344            }
2345        })
2346        .await;
2347
2348        shutdown.notify_one();
2349        drop(op_tx);
2350        handle.await.unwrap();
2351
2352        assert_eq!(
2353            completed.expect("the run must reach stage two and finish on wakes alone"),
2354            "complete"
2355        );
2356    }
2357
2358    /// The heartbeat's two levels. Under pressure it is worth an `info` line;
2359    /// idle it must not be, or a healthy daemon spams the log forever.
2360    #[tokio::test]
2361    async fn the_lane_heartbeat_distinguishes_pressure_from_idle() {
2362        leviath_testkit::with_tracing(|| async {
2363            // Idle: no agents, no pools touched, nothing queued.
2364            let mut host = host_with_full_pool(1);
2365            let idle = host.world_mut().lane_snapshot();
2366            assert!(!idle.is_under_pressure(), "an empty world is not pressured");
2367            assert_eq!(idle.inference_summary(), "none");
2368            host.log_lane_pressure(&idle); // the `debug` arm
2369
2370            // Two agents, one slot: one infers, one is queued behind a full pool.
2371            spawn(&mut host, "run-a", "agent-a");
2372            spawn(&mut host, "run-b", "agent-b");
2373            host.world_mut().run_to_fixed_point();
2374
2375            let busy = host.world_mut().lane_snapshot();
2376            assert_eq!(busy.agents.active, 2);
2377            assert_eq!(busy.inference_summary(), "m=1/1");
2378            assert!(
2379                busy.is_under_pressure(),
2380                "a full pool with active agents is exactly the state worth reporting"
2381            );
2382            host.log_lane_pressure(&busy); // the `info` arm
2383        })
2384        .await;
2385    }
2386
2387    /// A daemon with work queued and nothing moving is what issue #191 reported,
2388    /// and until now it looked identical to a busy one. Each re-drive that finds
2389    /// the lanes full and the world unchanged is one dead cycle.
2390    #[tokio::test]
2391    async fn re_drives_that_go_nowhere_under_pressure_count_as_dead_cycles() {
2392        leviath_testkit::with_tracing(|| async {
2393            // Two agents, one inference slot, and a provider that never answers:
2394            // one is stuck mid-call, the other is queued behind a full pool.
2395            let mut host = host_with_full_pool(1);
2396            spawn(&mut host, "run-a", "agent-a");
2397            spawn(&mut host, "run-b", "agent-b");
2398            host.world_mut().run_to_fixed_point();
2399            host.emit_events();
2400
2401            // The first re-drive has nothing to compare against.
2402            host.observe_redrive();
2403            assert_eq!(host.dead_cycles, 0, "the first cycle sets the baseline");
2404
2405            host.observe_redrive();
2406            assert_eq!(host.dead_cycles, 1, "a whole interval, nothing moved");
2407            host.observe_redrive();
2408            assert_eq!(host.dead_cycles, 2, "and another - this is the `warn` arm");
2409        })
2410        .await;
2411    }
2412
2413    /// Any sign of life clears the count. A daemon that moves once every few
2414    /// minutes is slow, not wedged, and must not accumulate towards relief.
2415    #[tokio::test]
2416    async fn a_run_that_moves_clears_the_dead_cycle_count() {
2417        let mut host = host_with_full_pool(1);
2418        let entity = spawn(&mut host, "run-a", "agent-a");
2419        spawn(&mut host, "run-b", "agent-b");
2420        host.world_mut().run_to_fixed_point();
2421        host.emit_events();
2422        host.observe_redrive();
2423        host.observe_redrive();
2424        assert_eq!(host.dead_cycles, 1, "wedged to begin with");
2425
2426        // One run advances an iteration, which is exactly what the fingerprint
2427        // is built to notice.
2428        host.world_mut()
2429            .world_mut()
2430            .get_mut::<AgentState>(entity)
2431            .expect("the agent is loaded")
2432            .iteration += 1;
2433        host.emit_events();
2434
2435        host.observe_redrive();
2436        assert_eq!(host.dead_cycles, 0, "something moved");
2437    }
2438
2439    /// Fill the world's tool lane and queue one batch behind it, returning a
2440    /// handle that releases the blocking batch.
2441    ///
2442    /// Uses the world's real lane rather than poking the counters, because the
2443    /// point of relief is that the queued batch actually runs afterwards.
2444    async fn wedge_the_tool_lane(host: &mut WorldHost) -> crate::cancel::CancelToken {
2445        let snapshot = host.world_mut().lane_snapshot();
2446        let stage = host
2447            .world_mut()
2448            .world()
2449            .resource::<crate::pipeline::ToolStage>()
2450            .clone();
2451        // A cancel token rather than a `Notify`: it latches, so a batch that has
2452        // not started yet still sees the release rather than waiting for a
2453        // wake-up that already happened.
2454        let release = crate::cancel::CancelToken::new();
2455        let submit = |exec: crate::tool_bridge::BoxedToolExec| {
2456            stage.stats.enqueued();
2457            stage
2458                .jobs
2459                .send(crate::tool_bridge::ToolJob {
2460                    // The lane never looks at the entity; these batches belong to
2461                    // no agent.
2462                    entity: Entity::from_raw_u32(9_001).expect("a small index is a valid id"),
2463                    exec,
2464                    cancel: crate::cancel::CancelToken::new(),
2465                })
2466                .expect("the lane is serving");
2467        };
2468        // Every batch here blocks until `release` fires. That is deliberate: a
2469        // batch that can finish on its own makes the lane's occupancy a moving
2470        // target, and the counts these tests assert on stop being deterministic.
2471        // `release_the_lane` lets them all go at the end.
2472        let blocker = || {
2473            let held = release.clone();
2474            submit(Box::new(move || {
2475                Box::pin(async move {
2476                    held.cancelled().await;
2477                    Vec::new()
2478                })
2479            }));
2480        };
2481        // Take whatever capacity is still free, so the lane is genuinely full
2482        // rather than merely busy.
2483        for _ in 0..snapshot.tools_workers.saturating_sub(snapshot.tools_busy) {
2484            blocker();
2485        }
2486        // Wait for them to actually be holding it before queueing anything
2487        // behind them: batches race each other for a permit, so one submitted
2488        // alongside could get in first.
2489        await_full_lane(host).await;
2490        blocker(); // and one behind them, which can only run once there is room
2491        await_saturation(host).await;
2492        release
2493    }
2494
2495    /// Block until every unit of the world's tool-lane capacity is held.
2496    async fn await_full_lane(host: &mut WorldHost) {
2497        await_lane(host, "the lane filled up", |snapshot| {
2498            snapshot.tools_busy >= snapshot.tools_workers
2499        })
2500        .await;
2501    }
2502
2503    /// Block until the world's tool lane reports itself saturated.
2504    async fn await_saturation(host: &mut WorldHost) {
2505        await_lane(host, "the lane saturated", |snapshot| {
2506            snapshot.tools_saturated
2507        })
2508        .await;
2509    }
2510
2511    /// Block until the world's tool lane drains its queue.
2512    async fn await_drained_queue(host: &mut WorldHost) {
2513        await_lane(host, "the queued batch got in", |snapshot| {
2514            snapshot.tools_queued == 0
2515        })
2516        .await;
2517    }
2518
2519    /// Poll the lane until `done`, or fail with `context`. Bounded so a wedge in
2520    /// the code under test fails the run instead of hanging it.
2521    async fn await_lane(
2522        host: &mut WorldHost,
2523        context: &str,
2524        done: fn(&crate::world::LaneSnapshot) -> bool,
2525    ) {
2526        tokio::time::timeout(std::time::Duration::from_secs(30), async {
2527            while !done(&host.world_mut().lane_snapshot()) {
2528                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2529            }
2530        })
2531        .await
2532        .expect(context);
2533    }
2534
2535    /// Let every wedged batch finish and wait for the lane to empty, so the
2536    /// batches are exercised end to end rather than abandoned mid-await.
2537    ///
2538    /// Takes a slice rather than one token so a test that wedged the lane twice
2539    /// releases both before waiting; releasing one and waiting would wait for
2540    /// batches still held by the other.
2541    async fn release_the_lane(host: &mut WorldHost, releases: &[crate::cancel::CancelToken]) {
2542        for release in releases {
2543            release.cancel();
2544        }
2545        await_lane(host, "the lane emptied", |snapshot| {
2546            snapshot.tools_busy == 0 && snapshot.tools_queued == 0
2547        })
2548        .await;
2549    }
2550
2551    /// The relief valve: a tool lane that has not drained in long enough gets
2552    /// wider, so whatever is queued behind the jam can run.
2553    ///
2554    /// Additive on purpose. Killing whatever holds the lane is the tempting
2555    /// reading of "reclaim stuck slots", and it is wrong: a run parked on an
2556    /// `ask_user` is behaving correctly, and an operator killing healthy
2557    /// `waiting` runs is the story behind issue #184.
2558    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2559    async fn a_lane_that_never_drains_is_widened_rather_than_emptied() {
2560        leviath_testkit::with_tracing(|| async {
2561            let mut host = host_with_full_pool(1);
2562            host.set_dead_cycles_before_relief(2);
2563            let release = wedge_the_tool_lane(&mut host).await;
2564
2565            host.observe_redrive(); // baseline
2566            host.observe_redrive(); // 1
2567            assert_eq!(host.relief_granted, 0, "still inside the grace period");
2568            host.observe_redrive(); // 2 → relief
2569            assert_eq!(host.relief_granted, 1, "the lane got wider");
2570            assert_eq!(
2571                host.dead_cycles, 0,
2572                "the streak restarts so relief is not granted again immediately"
2573            );
2574            assert_eq!(host.health().tools_workers, 2);
2575
2576            // Which is the whole point: the batch that was queued behind the
2577            // jam gets a permit, while the batch already holding one keeps it.
2578            await_drained_queue(&mut host).await;
2579            assert_eq!(host.world_mut().lane_snapshot().tools_busy, 2);
2580            release_the_lane(&mut host, &[release]).await;
2581        })
2582        .await;
2583    }
2584
2585    /// Relief is capped at one extra lane's worth over the daemon's life. If
2586    /// that much did not help, the problem is not capacity.
2587    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2588    async fn relief_stops_after_one_extra_lane_s_worth() {
2589        leviath_testkit::with_tracing(|| async {
2590            let mut host = host_with_full_pool(1);
2591            host.set_dead_cycles_before_relief(1);
2592            let release = wedge_the_tool_lane(&mut host).await;
2593
2594            host.observe_redrive();
2595            host.observe_redrive();
2596            assert_eq!(host.relief_granted, 1);
2597
2598            // Wedge it again at the wider width and keep pushing: the budget is
2599            // spent, so nothing more is handed out.
2600            let release_two = wedge_the_tool_lane(&mut host).await;
2601            for _ in 0..4 {
2602                host.observe_redrive();
2603            }
2604            assert_eq!(host.relief_granted, 1, "the budget was already spent");
2605            release_the_lane(&mut host, &[release, release_two]).await;
2606        })
2607        .await;
2608    }
2609
2610    /// Relief is off when the operator says so, and detection carries on
2611    /// regardless - the streak is still counted and still reported.
2612    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2613    async fn relief_can_be_turned_off_without_turning_off_detection() {
2614        leviath_testkit::with_tracing(|| async {
2615            let mut host = host_with_full_pool(1);
2616            host.set_dead_cycles_before_relief(0);
2617            let release = wedge_the_tool_lane(&mut host).await;
2618
2619            for _ in 0..4 {
2620                host.observe_redrive();
2621            }
2622            assert_eq!(host.relief_granted, 0, "relief is disabled");
2623            assert_eq!(host.dead_cycles, 3, "but the streak is still counted");
2624            release_the_lane(&mut host, &[release]).await;
2625        })
2626        .await;
2627    }
2628
2629    /// Every re-drive hands the sink a daemon-wide sample, including the quiet
2630    /// ones. A wedged daemon produces no per-run telemetry at all, which is
2631    /// exactly why the health sample cannot be conditional on something having
2632    /// happened.
2633    #[tokio::test]
2634    async fn each_re_drive_reports_lane_health_to_the_telemetry_sink() {
2635        let sink = Arc::new(leviath_core::telemetry::MemorySink::default());
2636        let mut host = host_with_full_pool(1);
2637        host.world_mut()
2638            .world_mut()
2639            .insert_resource(crate::telemetry::Telemetry(sink.clone()));
2640        spawn(&mut host, "run-a", "agent-a");
2641        spawn(&mut host, "run-b", "agent-b");
2642        host.world_mut().run_to_fixed_point();
2643        host.emit_events();
2644
2645        host.observe_redrive();
2646        host.observe_redrive();
2647
2648        let samples = sink.lane_samples();
2649        assert_eq!(samples.len(), 2, "one per re-drive");
2650        assert_eq!(samples[0].dead_cycles, 0);
2651        assert_eq!(samples[1].dead_cycles, 1, "the streak is carried through");
2652        assert_eq!(samples[1].agents_active, 2);
2653    }
2654
2655    /// The same tick reports which providers are out of service, and reports
2656    /// the empty case too: a collector needs that to see a provider come back,
2657    /// not merely stop being mentioned (issue #201).
2658    #[tokio::test]
2659    async fn each_re_drive_reports_providers_out_of_service() {
2660        let sink = Arc::new(leviath_core::telemetry::MemorySink::default());
2661        let mut host = host_with(vec![]);
2662        host.world_mut()
2663            .world_mut()
2664            .insert_resource(crate::telemetry::Telemetry(sink.clone()));
2665        let policy = crate::pipeline::CircuitPolicy {
2666            failures_before_open: 1,
2667            cooldown_secs: 300,
2668        };
2669        let mut circuits = crate::pipeline::ProviderCircuits::default();
2670        circuits.record_failure(
2671            "openrouter",
2672            leviath_providers::UnavailableReason::CreditsExhausted,
2673            chrono::Utc::now().timestamp(),
2674            &policy,
2675        );
2676        host.world_mut().world_mut().insert_resource(circuits);
2677        host.world_mut().world_mut().insert_resource(policy);
2678
2679        host.observe_redrive();
2680
2681        let samples = sink.provider_samples();
2682        assert_eq!(samples.len(), 1);
2683        assert_eq!(samples[0].len(), 1);
2684        assert_eq!(samples[0][0].provider, "openrouter");
2685        assert_eq!(samples[0][0].reason, "credits-exhausted");
2686        assert_eq!(samples[0][0].consecutive_failures, 1);
2687        assert!(samples[0][0].retry_in_secs > 0);
2688        // It also reaches `lev ps` through the health snapshot.
2689        assert_eq!(host.health().providers_down.len(), 1);
2690
2691        // The provider recovers, and the empty sample says so.
2692        host.world_mut()
2693            .world_mut()
2694            .resource_mut::<crate::pipeline::ProviderCircuits>()
2695            .record_success("openrouter");
2696        host.observe_redrive();
2697        assert!(sink.provider_samples()[1].is_empty());
2698        assert!(host.health().providers_down.is_empty());
2699    }
2700
2701    /// Stillness on its own is not a dead cycle. An idle daemon has nothing
2702    /// queued and nothing to do, and counting it would fire relief at every quiet
2703    /// spell.
2704    #[tokio::test]
2705    async fn an_idle_daemon_never_counts_a_dead_cycle() {
2706        let mut host = host_with_full_pool(1);
2707        host.emit_events();
2708        for _ in 0..3 {
2709            host.observe_redrive();
2710        }
2711        assert_eq!(host.dead_cycles, 0, "no pressure, no dead cycles");
2712    }
2713
2714    /// Terminal agents are counted apart from live ones, so "nothing is running"
2715    /// can't be read as "everything is running" just because finished runs are
2716    /// still loaded.
2717    #[tokio::test]
2718    async fn the_lane_snapshot_counts_agents_by_status() {
2719        let mut host = host_with(vec![]);
2720        let active = spawn(&mut host, "run-active", "a");
2721        let paused = spawn(&mut host, "run-paused", "b");
2722        let waiting = spawn(&mut host, "run-waiting", "c");
2723        let done = spawn(&mut host, "run-done", "d");
2724        let idle = spawn(&mut host, "run-idle", "e");
2725        host.world_mut().set_status(paused, AgentStatus::Paused);
2726        host.world_mut().set_status(waiting, AgentStatus::Waiting);
2727        host.world_mut().set_status(done, AgentStatus::Complete);
2728        host.world_mut().set_status(idle, AgentStatus::Idle);
2729
2730        let counts = host.world_mut().lane_snapshot().agents;
2731        assert_eq!(counts.active, 1);
2732        assert_eq!(counts.paused, 1);
2733        assert_eq!(counts.waiting, 1);
2734        assert_eq!(counts.terminal, 1);
2735        assert_eq!(counts.idle, 1);
2736        assert_eq!(
2737            counts.to_string(),
2738            "active=1 waiting=1 paused=1 idle=1 terminal=1"
2739        );
2740        // The other two terminal statuses land in the same bucket.
2741        host.world_mut().set_status(active, AgentStatus::Cancelled);
2742        host.world_mut().set_status(
2743            paused,
2744            AgentStatus::Error {
2745                message: "boom".to_string(),
2746            },
2747        );
2748        assert_eq!(host.world_mut().lane_snapshot().agents.terminal, 3);
2749    }
2750
2751    #[tokio::test]
2752    async fn status_and_list_reflect_registered_runs() {
2753        let mut host = host_with(vec![]);
2754        spawn(&mut host, "run-a", "agent-a");
2755
2756        let status = ask(&mut host, |reply| ControlOp::Status {
2757            run_id: "run-a".to_string(),
2758            reply,
2759        })
2760        .await;
2761        assert_eq!(status, Some(AgentStatus::Active));
2762
2763        let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
2764        assert_eq!(list.len(), 1);
2765        assert_eq!(list[0].run_id, "run-a");
2766        assert_eq!(list[0].status, AgentStatus::Active);
2767        // An active run is not waiting on anything, so there is nothing to explain.
2768        assert_eq!(list[0].wait_reason, None);
2769
2770        // Unknown run.
2771        let none = ask(&mut host, |reply| ControlOp::Status {
2772            run_id: "ghost".to_string(),
2773            reply,
2774        })
2775        .await;
2776        assert_eq!(none, None);
2777    }
2778
2779    #[tokio::test]
2780    async fn pause_resume_cancel_by_run_id() {
2781        let mut host = host_with(vec![]);
2782        spawn(&mut host, "run-a", "agent-a");
2783
2784        assert!(
2785            ask(&mut host, |reply| ControlOp::Pause {
2786                run_id: "run-a".to_string(),
2787                reply
2788            })
2789            .await
2790        );
2791        assert_eq!(
2792            host.world.agent_status(host.by_run_id["run-a"]),
2793            Some(AgentStatus::Paused)
2794        );
2795
2796        // Pausing an already-paused run refuses rather than reporting success.
2797        assert!(
2798            !ask(&mut host, |reply| ControlOp::Pause {
2799                run_id: "run-a".to_string(),
2800                reply
2801            })
2802            .await
2803        );
2804
2805        assert!(
2806            ask(&mut host, |reply| ControlOp::Resume {
2807                run_id: "run-a".to_string(),
2808                reply
2809            })
2810            .await
2811        );
2812        assert_eq!(
2813            host.world.agent_status(host.by_run_id["run-a"]),
2814            Some(AgentStatus::Active)
2815        );
2816        assert!(
2817            ask(&mut host, |reply| ControlOp::Cancel {
2818                run_id: "run-a".to_string(),
2819                reply
2820            })
2821            .await
2822        );
2823        assert_eq!(
2824            host.world.agent_status(host.by_run_id["run-a"]),
2825            Some(AgentStatus::Cancelled)
2826        );
2827
2828        // Unknown run ⇒ false.
2829        assert!(
2830            !ask(&mut host, |reply| ControlOp::Pause {
2831                run_id: "ghost".to_string(),
2832                reply
2833            })
2834            .await
2835        );
2836        assert!(
2837            !ask(&mut host, |reply| ControlOp::Resume {
2838                run_id: "ghost".to_string(),
2839                reply
2840            })
2841            .await
2842        );
2843        assert!(
2844            !ask(&mut host, |reply| ControlOp::Cancel {
2845                run_id: "ghost".to_string(),
2846                reply
2847            })
2848            .await
2849        );
2850    }
2851
2852    #[tokio::test]
2853    async fn spawn_op_uses_installed_spawner_and_registers() {
2854        let mut host = host_with(vec![]);
2855        host.set_spawner(Box::new(|world, args| {
2856            Ok(world.spawn_agent((agent_state(&args.run_id),)))
2857        }));
2858
2859        let result = ask(&mut host, |reply| ControlOp::Spawn {
2860            args: Box::new(SpawnArgs {
2861                run_id: "r1".to_string(),
2862                ..Default::default()
2863            }),
2864            reply,
2865        })
2866        .await;
2867        assert_eq!(result, Ok("r1".to_string()));
2868
2869        // The run is now registered, so Status resolves it.
2870        let status = ask(&mut host, |reply| ControlOp::Status {
2871            run_id: "r1".to_string(),
2872            reply,
2873        })
2874        .await;
2875        assert_eq!(status, Some(AgentStatus::Active));
2876    }
2877
2878    #[tokio::test]
2879    async fn spawn_op_propagates_spawner_error() {
2880        let mut host = host_with(vec![]);
2881        host.set_spawner(Box::new(|_world, _args| Err("bad blueprint".to_string())));
2882        let result = ask(&mut host, |reply| ControlOp::Spawn {
2883            args: Box::new(SpawnArgs::default()),
2884            reply,
2885        })
2886        .await;
2887        assert_eq!(result, Err("bad blueprint".to_string()));
2888    }
2889
2890    #[tokio::test]
2891    async fn spawn_op_contains_a_panicking_spawner() {
2892        // A panic while building an agent (bad manifest, sandbox blow-up) must
2893        // not unwind the daemon's serve task - the run just fails to start.
2894        let mut host = host_with(vec![]);
2895        host.set_spawner(Box::new(|_world, _args| panic!("simulated spawn panic")));
2896        let (tx, rx) = oneshot::channel();
2897        crate::test_support::with_silenced_panics(|| {
2898            host.handle(ControlOp::Spawn {
2899                args: Box::new(SpawnArgs::default()),
2900                reply: tx,
2901            });
2902        });
2903        assert_eq!(rx.await.unwrap(), Err("agent spawn panicked".to_string()));
2904        // The host is still usable afterwards, and the run never registered.
2905        let status = ask(&mut host, |reply| ControlOp::Status {
2906            run_id: SpawnArgs::default().run_id,
2907            reply,
2908        })
2909        .await;
2910        assert!(status.is_none());
2911    }
2912
2913    #[tokio::test]
2914    async fn spawn_op_errors_without_a_spawner() {
2915        let mut host = host_with(vec![]);
2916        let result = ask(&mut host, |reply| ControlOp::Spawn {
2917            args: Box::new(SpawnArgs::default()),
2918            reply,
2919        })
2920        .await;
2921        assert!(result.unwrap_err().contains("cannot spawn"));
2922    }
2923
2924    // ─── sub-agent bridge ──────────────────────────────────────────────────
2925
2926    async fn ask_sub<T>(
2927        host: &mut WorldHost,
2928        make: impl FnOnce(oneshot::Sender<T>) -> SubAgentOp,
2929    ) -> T {
2930        let (tx, rx) = oneshot::channel();
2931        host.handle_subagent(make(tx));
2932        rx.await.unwrap()
2933    }
2934
2935    /// A spawner that adds a bare child agent and returns it.
2936    fn child_spawner() -> Spawner {
2937        Box::new(|world, args| Ok(world.spawn_agent((agent_state(&args.run_id),))))
2938    }
2939
2940    #[tokio::test]
2941    async fn subagent_spawn_links_child_and_registers() {
2942        let mut host = host_with(vec![]);
2943        host.set_spawner(child_spawner());
2944        let parent = spawn(&mut host, "parent", "parent");
2945
2946        let result = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
2947            args: Box::new(SpawnArgs {
2948                run_id: "child".to_string(),
2949                ..Default::default()
2950            }),
2951            parent_run_id: "parent".to_string(),
2952            max_depth: 3,
2953            reply,
2954        })
2955        .await;
2956        assert_eq!(result, Ok("child".to_string()));
2957
2958        let child = host.by_run_id["child"];
2959        // The child links back to the parent at depth 1.
2960        let pref = host.world.world().get::<ParentRef>(child).unwrap();
2961        assert_eq!(pref.parent_entity, parent);
2962        assert_eq!(pref.depth, 1);
2963        // The parent tracks the child.
2964        let kids = host.world.world().get::<SubAgentChildren>(parent).unwrap();
2965        assert_eq!(kids.children, vec![child]);
2966    }
2967
2968    #[tokio::test]
2969    async fn subagent_spawn_appends_to_existing_children() {
2970        let mut host = host_with(vec![]);
2971        host.set_spawner(child_spawner());
2972        spawn(&mut host, "parent", "parent");
2973        for id in ["c1", "c2"] {
2974            let r = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
2975                args: Box::new(SpawnArgs {
2976                    run_id: id.to_string(),
2977                    ..Default::default()
2978                }),
2979                parent_run_id: "parent".to_string(),
2980                max_depth: 3,
2981                reply,
2982            })
2983            .await;
2984            assert!(r.is_ok());
2985        }
2986        let parent = host.by_run_id["parent"];
2987        let kids = host.world.world().get::<SubAgentChildren>(parent).unwrap();
2988        assert_eq!(kids.children.len(), 2);
2989    }
2990
2991    #[tokio::test]
2992    async fn subagent_spawn_rejects_beyond_max_depth() {
2993        let mut host = host_with(vec![]);
2994        host.set_spawner(child_spawner());
2995        spawn(&mut host, "parent", "parent");
2996        let result = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
2997            args: Box::new(SpawnArgs {
2998                run_id: "child".to_string(),
2999                ..Default::default()
3000            }),
3001            parent_run_id: "parent".to_string(),
3002            max_depth: 0, // child would be depth 1 > 0
3003            reply,
3004        })
3005        .await;
3006        assert!(result.unwrap_err().contains("depth limit"));
3007        assert!(!host.by_run_id.contains_key("child"));
3008    }
3009
3010    #[tokio::test]
3011    async fn subagent_spawn_unknown_parent_and_no_spawner_and_spawner_error() {
3012        // Unknown parent.
3013        let mut host = host_with(vec![]);
3014        host.set_spawner(child_spawner());
3015        let r = ask_sub(&mut host, |reply| SubAgentOp::Spawn {
3016            args: Box::new(SpawnArgs::default()),
3017            parent_run_id: "ghost".to_string(),
3018            max_depth: 3,
3019            reply,
3020        })
3021        .await;
3022        assert!(r.unwrap_err().contains("not live"));
3023
3024        // No spawner installed.
3025        let mut host2 = host_with(vec![]);
3026        spawn(&mut host2, "parent", "parent");
3027        let r = ask_sub(&mut host2, |reply| SubAgentOp::Spawn {
3028            args: Box::new(SpawnArgs::default()),
3029            parent_run_id: "parent".to_string(),
3030            max_depth: 3,
3031            reply,
3032        })
3033        .await;
3034        assert!(r.unwrap_err().contains("cannot spawn"));
3035
3036        // Spawner rejects.
3037        let mut host3 = host_with(vec![]);
3038        host3.set_spawner(Box::new(|_w, _a| Err("bad blueprint".to_string())));
3039        spawn(&mut host3, "parent", "parent");
3040        let r = ask_sub(&mut host3, |reply| SubAgentOp::Spawn {
3041            args: Box::new(SpawnArgs::default()),
3042            parent_run_id: "parent".to_string(),
3043            max_depth: 3,
3044            reply,
3045        })
3046        .await;
3047        assert_eq!(r, Err("bad blueprint".to_string()));
3048    }
3049
3050    #[tokio::test]
3051    async fn subagent_check_reports_status_or_none() {
3052        let mut host = host_with(vec![]);
3053        spawn(&mut host, "run-a", "run-a");
3054        let status = ask_sub(&mut host, |reply| SubAgentOp::Check {
3055            run_id: "run-a".to_string(),
3056            reply,
3057        })
3058        .await;
3059        assert_eq!(status, Some(AgentStatus::Active));
3060
3061        let none = ask_sub(&mut host, |reply| SubAgentOp::Check {
3062            run_id: "ghost".to_string(),
3063            reply,
3064        })
3065        .await;
3066        assert_eq!(none, None);
3067    }
3068
3069    /// `send_to_agent` and `kill_agent` took any run id at all, so an agent
3070    /// could reach into an unrelated run - cancel it, inject text, or hand it
3071    /// data that arrives `Public` regardless of the sender's taint. That last
3072    /// one is a laundering channel straight through taint tracking.
3073    /// The converse of the refusal: a run the caller *did* spawn is reachable,
3074    /// so scoping did not simply block everything. This also walks the
3075    /// `SubAgentChildren` link rather than matching the caller itself.
3076    #[tokio::test]
3077    async fn subagent_ops_reach_a_run_the_caller_spawned() {
3078        let mut host = host_with(vec![]);
3079        let parent = spawn(&mut host, "parent", "parent");
3080        let child = spawn(&mut host, "child", "child");
3081        host.world_mut()
3082            .world_mut()
3083            .entity_mut(parent)
3084            .insert(SubAgentChildren {
3085                children: vec![child],
3086                max_child_depth: 3,
3087            });
3088
3089        let delivered = ask_sub(&mut host, |reply| SubAgentOp::Send {
3090            run_id: "child".to_string(),
3091            caller_run_id: "parent".to_string(),
3092            content: "carry on".to_string(),
3093            target_region: None,
3094            reply,
3095        })
3096        .await;
3097        assert!(delivered, "a run we spawned is ours to message");
3098    }
3099
3100    #[tokio::test]
3101    async fn subagent_ops_refuse_a_run_outside_the_callers_tree() {
3102        let mut host = host_with(vec![]);
3103        spawn(&mut host, "run-a", "run-a");
3104        spawn(&mut host, "outsider", "outsider");
3105
3106        let delivered = ask_sub(&mut host, |reply| SubAgentOp::Send {
3107            run_id: "outsider".to_string(),
3108            caller_run_id: "run-a".to_string(),
3109            content: "take this".to_string(),
3110            target_region: None,
3111            reply,
3112        })
3113        .await;
3114        assert!(!delivered, "a run we did not spawn is not ours to message");
3115
3116        let killed = ask_sub(&mut host, |reply| SubAgentOp::Kill {
3117            run_id: "outsider".to_string(),
3118            caller_run_id: "run-a".to_string(),
3119            reply,
3120        })
3121        .await;
3122        assert!(!killed, "nor ours to cancel");
3123
3124        // A run id that resolves to nothing at all is likewise not ours - the
3125        // walk never starts, rather than defaulting to reachable.
3126        let phantom = ask_sub(&mut host, |reply| SubAgentOp::Send {
3127            run_id: "no-such-run".to_string(),
3128            caller_run_id: "run-a".to_string(),
3129            content: "hello?".to_string(),
3130            target_region: None,
3131            reply,
3132        })
3133        .await;
3134        assert!(!phantom, "an unknown run id is in nobody's tree");
3135    }
3136
3137    #[tokio::test]
3138    async fn subagent_send_delivers_to_inbox() {
3139        let mut host = host_with(vec![]);
3140        spawn(&mut host, "run-a", "run-a");
3141        let ok = ask_sub(&mut host, |reply| SubAgentOp::Send {
3142            run_id: "run-a".to_string(),
3143            caller_run_id: "run-a".to_string(),
3144            content: "hello child".to_string(),
3145            target_region: None,
3146            reply,
3147        })
3148        .await;
3149        assert!(ok);
3150    }
3151
3152    /// The op's `target_region` reaches the named region, not just the
3153    /// default conversation. The `send_to_agent` tool advertised this
3154    /// parameter from the start, but the op had no field to carry it, so it
3155    /// was silently dropped on this path.
3156    #[tokio::test]
3157    async fn subagent_send_delivers_into_the_target_region() {
3158        let mut host = host_with(vec![]);
3159        let e = spawn(&mut host, "run-a", "run-a");
3160        host.world
3161            .world_mut()
3162            .get_mut::<crate::components::ContextWindow>(e)
3163            .unwrap()
3164            .add_region(Region::new(
3165                "notes".to_string(),
3166                RegionKind::Clearable,
3167                5000,
3168            ));
3169
3170        let ok = ask_sub(&mut host, |reply| SubAgentOp::Send {
3171            run_id: "run-a".to_string(),
3172            caller_run_id: "run-a".to_string(),
3173            content: "filed under notes".to_string(),
3174            target_region: Some("notes".to_string()),
3175            reply,
3176        })
3177        .await;
3178        assert!(ok);
3179
3180        host.world.tick(); // intake → inbox → window
3181        let window = host
3182            .world
3183            .world()
3184            .get::<crate::components::ContextWindow>(e)
3185            .unwrap();
3186        assert!(window.get_region("notes").unwrap().current_tokens > 0);
3187        assert_eq!(window.get_region("conversation").unwrap().current_tokens, 0);
3188    }
3189
3190    #[tokio::test]
3191    async fn subagent_kill_cancels_the_whole_tree() {
3192        let mut host = host_with(vec![]);
3193        host.set_spawner(child_spawner());
3194        spawn(&mut host, "parent", "parent");
3195        ask_sub(&mut host, |reply| SubAgentOp::Spawn {
3196            args: Box::new(SpawnArgs {
3197                run_id: "child".to_string(),
3198                ..Default::default()
3199            }),
3200            parent_run_id: "parent".to_string(),
3201            max_depth: 3,
3202            reply,
3203        })
3204        .await
3205        .unwrap();
3206
3207        let ok = ask_sub(&mut host, |reply| SubAgentOp::Kill {
3208            run_id: "parent".to_string(),
3209            caller_run_id: "parent".to_string(),
3210            reply,
3211        })
3212        .await;
3213        assert!(ok);
3214        assert_eq!(
3215            host.world.agent_status(host.by_run_id["parent"]),
3216            Some(AgentStatus::Cancelled)
3217        );
3218        assert_eq!(
3219            host.world.agent_status(host.by_run_id["child"]),
3220            Some(AgentStatus::Cancelled)
3221        );
3222
3223        // Killing an unknown run is a no-op.
3224        let miss = ask_sub(&mut host, |reply| SubAgentOp::Kill {
3225            run_id: "ghost".to_string(),
3226            caller_run_id: "ghost".to_string(),
3227            reply,
3228        })
3229        .await;
3230        assert!(!miss);
3231    }
3232
3233    /// A user-facing cancel must reach the sub-agent tree, not just the root -
3234    /// otherwise the children keep running with nobody to report to. Before this,
3235    /// only the model-facing `kill_agent` tool cascaded.
3236    #[tokio::test]
3237    async fn cancel_cascades_to_the_whole_tree() {
3238        let mut host = host_with(vec![]);
3239        host.set_spawner(child_spawner());
3240        spawn(&mut host, "parent", "parent");
3241        ask_sub(&mut host, |reply| SubAgentOp::Spawn {
3242            args: Box::new(SpawnArgs {
3243                run_id: "child".to_string(),
3244                ..Default::default()
3245            }),
3246            parent_run_id: "parent".to_string(),
3247            max_depth: 3,
3248            reply,
3249        })
3250        .await
3251        .unwrap();
3252
3253        assert!(
3254            ask(&mut host, |reply| ControlOp::Cancel {
3255                run_id: "parent".to_string(),
3256                reply
3257            })
3258            .await
3259        );
3260        assert_eq!(
3261            host.world.agent_status(host.by_run_id["child"]),
3262            Some(AgentStatus::Cancelled),
3263            "cancelling the parent cancels its children"
3264        );
3265    }
3266
3267    /// A child that was already reaped is skipped rather than tripping the
3268    /// cancel: `SubAgentChildren` still names it, but the entity is gone, so
3269    /// there is no agent id to close interactions for.
3270    #[tokio::test]
3271    async fn cancel_tolerates_a_child_that_has_already_been_reaped() {
3272        let mut host = host_with(vec![]);
3273        let parent = spawn(&mut host, "parent", "parent");
3274        let ghost = host.world_mut().spawn_agent((agent_state("ghost"),));
3275        host.world_mut()
3276            .world_mut()
3277            .entity_mut(parent)
3278            .insert(SubAgentChildren {
3279                children: vec![ghost],
3280                max_child_depth: 3,
3281            });
3282        host.world_mut().world_mut().despawn(ghost);
3283
3284        assert!(
3285            ask(&mut host, |reply| ControlOp::Cancel {
3286                run_id: "parent".to_string(),
3287                reply
3288            })
3289            .await,
3290            "the parent is still cancelled"
3291        );
3292        assert_eq!(
3293            host.world.agent_status(parent),
3294            Some(AgentStatus::Cancelled)
3295        );
3296    }
3297
3298    /// Cancelling a run closes its open prompts. The blocked `ask` waits off the
3299    /// lane, so it no longer starves anyone, but a prompt left open for a run
3300    /// that no longer exists is still surfaced to whoever is meant to answer it.
3301    #[tokio::test]
3302    async fn cancel_closes_the_runs_open_interactions() {
3303        let mut host = host_with(vec![]);
3304        let hub = host.interactions();
3305        spawn(&mut host, "run-a", "agent-a");
3306
3307        let backend = hub.backend_for("agent-a");
3308        let asking = tokio::spawn(async move {
3309            backend
3310                .ask(InteractionRequest::free_text("q", "ask", "stage", true))
3311                .await
3312        });
3313        // Wait for the ask to register, then let the host emit it - so the
3314        // emitted-interaction set is non-empty and the cancel has something to
3315        // prune, rather than pruning an empty set.
3316        while hub.pending().is_empty() {
3317            tokio::task::yield_now().await;
3318        }
3319        host.emit_events();
3320        assert!(
3321            !host.emitted_interactions.is_empty(),
3322            "the open request was emitted"
3323        );
3324
3325        ask(&mut host, |reply| ControlOp::Cancel {
3326            run_id: "run-a".to_string(),
3327            reply,
3328        })
3329        .await;
3330
3331        // The blocked future is released rather than parked forever. Bounded,
3332        // because the regression this guards *is* an unbounded wait: without the
3333        // per-agent cancel this await simply never returns, and a test that hangs
3334        // rather than fails is worse than no test.
3335        tokio::time::timeout(std::time::Duration::from_secs(5), asking)
3336            .await
3337            .expect("cancelling the run releases its blocked ask")
3338            .expect("the ask task did not panic");
3339        // ...and the request stops being advertised to `lev respond` / the
3340        // dashboard for a run that is going away.
3341        assert!(hub.pending().is_empty(), "no orphaned prompt is left open");
3342        assert!(
3343            host.emitted_interactions.is_empty(),
3344            "and it is pruned from the emitted set, not re-announced forever"
3345        );
3346    }
3347
3348    /// The floor under every kill: a run the reloader can't rebuild must still be
3349    /// terminated, via the daemon's on-disk force-terminator. Replying `false` and
3350    /// writing nothing is what made such a run permanent.
3351    #[tokio::test]
3352    async fn cancel_falls_back_to_the_force_terminator_when_the_world_cannot_hold_the_run() {
3353        let mut host = host_with(vec![]);
3354        // A reloader that always declines - the deleted-blueprint case.
3355        host.set_reloader(Box::new(|_world, _run_id| None));
3356        let terminated = Arc::new(Mutex::new(Vec::new()));
3357        host.set_force_terminator(recording_terminator(terminated.clone()));
3358
3359        assert!(
3360            ask(&mut host, |reply| ControlOp::Cancel {
3361                run_id: "unreloadable".to_string(),
3362                reply
3363            })
3364            .await,
3365            "a run that can't be reloaded is still terminated"
3366        );
3367        assert!(
3368            !ask(&mut host, |reply| ControlOp::Cancel {
3369                run_id: "never-existed".to_string(),
3370                reply
3371            })
3372            .await,
3373            "`false` is reserved for a run that exists nowhere"
3374        );
3375        assert_eq!(
3376            *terminated.lock().unwrap(),
3377            vec!["unreloadable".to_string(), "never-existed".to_string()]
3378        );
3379    }
3380
3381    /// A live run is cancelled in the world; the on-disk fallback is not consulted
3382    /// (the persistence lane records the status change).
3383    #[tokio::test]
3384    async fn cancel_does_not_force_terminate_a_run_it_could_cancel() {
3385        let mut host = host_with(vec![]);
3386        spawn(&mut host, "run-a", "agent-a");
3387        let terminated = Arc::new(Mutex::new(Vec::new()));
3388        host.set_force_terminator(recording_terminator(terminated.clone()));
3389
3390        assert!(
3391            ask(&mut host, |reply| ControlOp::Cancel {
3392                run_id: "run-a".to_string(),
3393                reply
3394            })
3395            .await
3396        );
3397        assert_eq!(
3398            host.world.agent_status(host.by_run_id["run-a"]),
3399            Some(AgentStatus::Cancelled)
3400        );
3401        assert!(
3402            terminated.lock().unwrap().is_empty(),
3403            "the disk fallback stayed unused"
3404        );
3405    }
3406
3407    /// Agents that enter the world outside a `Spawn` op (fan-out workers, built
3408    /// directly by the fan-out spawner) are adopted into the run-id map, so they
3409    /// are listed, reaped and - the point here - cancellable by id. Left
3410    /// unregistered, a cancel missed the map and paged a *second* copy of the run
3411    /// in from disk while the original kept going.
3412    #[tokio::test]
3413    async fn unregistered_world_agents_are_adopted_and_become_cancellable() {
3414        let mut host = host_with(vec![]);
3415        let entity = host.world_mut().spawn_agent((
3416            agent_state("worker"),
3417            RunMetadata {
3418                run_id: "worker-run".to_string(),
3419                agent_name: "w".to_string(),
3420                agent_path: String::new(),
3421                task: String::new(),
3422                model: None,
3423                workdir: String::new(),
3424                num_stages: 1,
3425                started_at: 0,
3426                parent_run_id: None,
3427                metadata: Default::default(),
3428                callback_url: None,
3429                callback_secret: None,
3430                title: None,
3431                unattended: false,
3432                read_paths: None,
3433            },
3434        ));
3435        assert!(
3436            !host.by_run_id.contains_key("worker-run"),
3437            "not registered by the spawn itself"
3438        );
3439
3440        host.emit_events();
3441
3442        assert_eq!(host.live_entity("worker-run"), Some(entity), "adopted");
3443        // A reloader that would mint a duplicate if the map were still missing it.
3444        host.set_reloader(paging_reloader());
3445        assert!(
3446            ask(&mut host, |reply| ControlOp::Cancel {
3447                run_id: "worker-run".to_string(),
3448                reply
3449            })
3450            .await
3451        );
3452        assert_eq!(
3453            host.world.agent_status(entity),
3454            Some(AgentStatus::Cancelled),
3455            "the original entity is cancelled, not a reloaded copy"
3456        );
3457    }
3458
3459    #[tokio::test]
3460    async fn interaction_ops_list_answer_and_cancel() {
3461        let mut host = host_with(vec![]);
3462        let hub = host.interactions();
3463        let backend = hub.backend_for("agent-a");
3464
3465        // An agent's ask is registered on the hub.
3466        let asking = tokio::spawn(async move {
3467            backend
3468                .ask(leviath_core::interaction::InteractionRequest::free_text(
3469                    "q1", "prompt?", "stage", true,
3470                ))
3471                .await
3472        });
3473        for _ in 0..8 {
3474            tokio::task::yield_now().await;
3475        }
3476
3477        // ListInteractions surfaces it.
3478        let list = ask(&mut host, |reply| ControlOp::ListInteractions { reply }).await;
3479        assert_eq!(list.len(), 1);
3480        assert_eq!(list[0].0, "agent-a");
3481
3482        // AnswerInteraction fulfils it.
3483        let ok = ask(&mut host, |reply| ControlOp::AnswerInteraction {
3484            response: leviath_core::interaction::InteractionResponse::text("q1", "hi"),
3485            reply,
3486        })
3487        .await;
3488        assert!(ok);
3489        assert_eq!(asking.await.unwrap().value.as_deref(), Some("hi"));
3490
3491        // CancelInteraction on an unknown id ⇒ false.
3492        let cancelled = ask(&mut host, |reply| ControlOp::CancelInteraction {
3493            request_id: "gone".to_string(),
3494            reply,
3495        })
3496        .await;
3497        assert!(!cancelled);
3498    }
3499
3500    #[tokio::test]
3501    async fn cancel_interaction_op_wakes_asker() {
3502        let mut host = host_with(vec![]);
3503        let backend = host.interactions().backend_for("agent-a");
3504        let asking = tokio::spawn(async move {
3505            backend
3506                .ask(leviath_core::interaction::InteractionRequest::free_text(
3507                    "q2", "p", "s", true,
3508                ))
3509                .await
3510        });
3511        for _ in 0..8 {
3512            tokio::task::yield_now().await;
3513        }
3514
3515        let ok = ask(&mut host, |reply| ControlOp::CancelInteraction {
3516            request_id: "q2".to_string(),
3517            reply,
3518        })
3519        .await;
3520        assert!(ok);
3521        assert_eq!(asking.await.unwrap().request_id, "q2");
3522    }
3523
3524    #[tokio::test]
3525    async fn message_op_is_delivered() {
3526        let mut host = host_with(vec![]);
3527        let e = spawn(&mut host, "run-a", "agent-a");
3528
3529        let ok = ask(&mut host, |reply| ControlOp::Message {
3530            agent_id: "agent-a".to_string(),
3531            content: "hi".to_string(),
3532            target_region: Some("conversation".to_string()),
3533            reply,
3534        })
3535        .await;
3536        assert!(ok);
3537
3538        // One tick delivers the message into context.
3539        host.world_mut().tick();
3540        assert!(
3541            host.world
3542                .world()
3543                .get::<crate::components::ContextWindow>(e)
3544                .unwrap()
3545                .get_region("conversation")
3546                .unwrap()
3547                .current_tokens
3548                > 0
3549        );
3550    }
3551
3552    #[tokio::test]
3553    async fn serve_drives_agents_and_handles_ops_until_shutdown() {
3554        let mut host = host_with(vec![text("t1"), text("t2"), text("t3"), text("t4")]);
3555        spawn(&mut host, "run-a", "agent-a");
3556        let shutdown = host.world_mut().shutdown_handle();
3557        // Watch the event stream rather than the entity: a run that finishes is
3558        // reaped out of the world once it has been seen terminal, so the
3559        // broadcast is the durable record that it ran to completion.
3560        let mut events = host.subscribe();
3561        let (op_tx, op_rx) = mpsc::unbounded_channel();
3562
3563        let handle = tokio::spawn(async move {
3564            host.serve(op_rx).await;
3565        });
3566
3567        // Query status via the live serve loop.
3568        let (tx, rx) = oneshot::channel();
3569        op_tx
3570            .send(ControlOp::Status {
3571                run_id: "run-a".to_string(),
3572                reply: tx,
3573            })
3574            .unwrap();
3575        let _ = rx.await.unwrap();
3576
3577        // The agent ran to completion under the serve loop.
3578        let completed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3579            loop {
3580                if let Ok(WorldEvent::Completed { run_id, status, .. }) = events.recv().await {
3581                    return (run_id, status);
3582                }
3583            }
3584        })
3585        .await
3586        .expect("the serve loop must drive the agent to a terminal status");
3587        assert_eq!(completed, ("run-a".to_string(), "complete".to_string()));
3588
3589        shutdown.notify_one();
3590        handle.await.unwrap();
3591    }
3592
3593    #[tokio::test]
3594    async fn serve_awaits_spawn_preprocessor_before_spawning() {
3595        use std::sync::atomic::{AtomicBool, Ordering};
3596        let mut host = host_with(vec![]);
3597        let ran = Arc::new(AtomicBool::new(false));
3598        let ran_pp = ran.clone();
3599        host.set_spawn_preprocessor(Box::new(move |_args| {
3600            let ran = ran_pp.clone();
3601            Box::pin(async move {
3602                ran.store(true, Ordering::SeqCst);
3603            })
3604        }));
3605        let ran_spawn = ran.clone();
3606        host.set_spawner(Box::new(move |world, args| {
3607            // The preprocessor must have completed before the spawner runs.
3608            assert!(ran_spawn.load(Ordering::SeqCst));
3609            Ok(world.spawn_agent((agent_state(&args.run_id),)))
3610        }));
3611        let (op_tx, op_rx) = mpsc::unbounded_channel();
3612        let handle = tokio::spawn(async move {
3613            host.serve(op_rx).await;
3614        });
3615        let (tx, rx) = oneshot::channel();
3616        op_tx
3617            .send(ControlOp::Spawn {
3618                args: Box::new(SpawnArgs {
3619                    run_id: "rp".to_string(),
3620                    ..Default::default()
3621                }),
3622                reply: tx,
3623            })
3624            .unwrap();
3625        let result = rx.await.unwrap();
3626        drop(op_tx); // close the channel so serve() returns
3627        handle.await.unwrap();
3628        assert_eq!(result, Ok("rp".to_string()));
3629        assert!(ran.load(Ordering::SeqCst), "preprocessor ran");
3630    }
3631
3632    #[tokio::test]
3633    async fn serve_awaits_preprocessor_for_subagent_spawn() {
3634        use std::sync::atomic::{AtomicUsize, Ordering};
3635        let mut host = host_with(vec![]);
3636        host.set_spawner(child_spawner());
3637        // An inert parent: no `ReadyToInfer`, so it never infers, never errors on
3638        // the empty response script, and stays live for the child to attach to.
3639        let parent = host.world_mut().spawn_agent((agent_state("parent"),));
3640        host.register("parent", parent);
3641        // Count preprocessor invocations: it must fire for the sub-agent Spawn,
3642        // and NOT for the non-Spawn Check op (the `_ => None` arm).
3643        let calls = Arc::new(AtomicUsize::new(0));
3644        let calls_pp = calls.clone();
3645        host.set_spawn_preprocessor(Box::new(move |_args| {
3646            let calls = calls_pp.clone();
3647            Box::pin(async move {
3648                calls.fetch_add(1, Ordering::SeqCst);
3649            })
3650        }));
3651        let sub_tx = host.subagent_sender();
3652        let shutdown = host.world_mut().shutdown_handle();
3653        let (op_tx, op_rx) = mpsc::unbounded_channel();
3654        let handle = tokio::spawn(async move {
3655            host.serve(op_rx).await;
3656        });
3657
3658        // A non-Spawn sub-agent op does not invoke the preprocessor.
3659        let (ctx, crx) = oneshot::channel();
3660        sub_tx
3661            .send(SubAgentOp::Check {
3662                run_id: "parent".to_string(),
3663                reply: ctx,
3664            })
3665            .unwrap();
3666        let _ = crx.await.unwrap();
3667
3668        // A sub-agent Spawn does.
3669        let (stx, srx) = oneshot::channel();
3670        sub_tx
3671            .send(SubAgentOp::Spawn {
3672                args: Box::new(SpawnArgs {
3673                    run_id: "child".to_string(),
3674                    ..Default::default()
3675                }),
3676                parent_run_id: "parent".to_string(),
3677                max_depth: 3,
3678                reply: stx,
3679            })
3680            .unwrap();
3681        assert_eq!(srx.await.unwrap(), Ok("child".to_string()));
3682
3683        shutdown.notify_one();
3684        drop(op_tx);
3685        handle.await.unwrap();
3686        assert_eq!(
3687            calls.load(Ordering::SeqCst),
3688            1,
3689            "only the Spawn preprocessed"
3690        );
3691    }
3692
3693    #[tokio::test]
3694    async fn serve_spawns_without_a_preprocessor() {
3695        // A Spawn op through serve() with no preprocessor installed exercises the
3696        // `None` arm of the preprocessor branch.
3697        let mut host = host_with(vec![]);
3698        host.set_spawner(Box::new(|world, args| {
3699            Ok(world.spawn_agent((agent_state(&args.run_id),)))
3700        }));
3701        let (op_tx, op_rx) = mpsc::unbounded_channel();
3702        let handle = tokio::spawn(async move {
3703            host.serve(op_rx).await;
3704        });
3705        let (tx, rx) = oneshot::channel();
3706        op_tx
3707            .send(ControlOp::Spawn {
3708                args: Box::new(SpawnArgs {
3709                    run_id: "np".to_string(),
3710                    ..Default::default()
3711                }),
3712                reply: tx,
3713            })
3714            .unwrap();
3715        let result = rx.await.unwrap();
3716        drop(op_tx);
3717        handle.await.unwrap();
3718        assert_eq!(result, Ok("np".to_string()));
3719    }
3720
3721    #[tokio::test]
3722    async fn shutdown_op_stops_the_serve_loop() {
3723        let mut host = host_with(vec![]);
3724        let (op_tx, op_rx) = mpsc::unbounded_channel();
3725        let handle = tokio::spawn(async move { host.serve(op_rx).await });
3726
3727        let (tx, rx) = oneshot::channel();
3728        op_tx.send(ControlOp::Shutdown { reply: tx }).unwrap();
3729        assert!(rx.await.unwrap());
3730        // The serve loop returns once the world's shutdown is signalled.
3731        handle.await.unwrap();
3732    }
3733
3734    #[tokio::test]
3735    async fn flush_and_stop_delegates_to_the_world() {
3736        // The host's flush-and-stop drains the world's persistence lane; calling it
3737        // (even with no agents) returns cleanly and is idempotent.
3738        let mut host = host_with(vec![]);
3739        host.flush_and_stop().await;
3740        host.flush_and_stop().await; // second call is a no-op
3741    }
3742
3743    #[tokio::test]
3744    async fn serve_loop_services_subagent_ops_via_the_sender() {
3745        let mut host = host_with(vec![]);
3746        spawn(&mut host, "run-a", "run-a");
3747        let sub_tx = host.subagent_sender();
3748        let (op_tx, op_rx) = mpsc::unbounded_channel();
3749        let handle = tokio::spawn(async move { host.serve(op_rx).await });
3750
3751        // A Check submitted on the sub-agent channel is serviced by the serve loop.
3752        let (tx, rx) = oneshot::channel();
3753        sub_tx
3754            .send(SubAgentOp::Check {
3755                run_id: "run-a".to_string(),
3756                reply: tx,
3757            })
3758            .unwrap();
3759        assert!(rx.await.unwrap().is_some());
3760
3761        let (stx, srx) = oneshot::channel();
3762        op_tx.send(ControlOp::Shutdown { reply: stx }).unwrap();
3763        assert!(srx.await.unwrap());
3764        handle.await.unwrap();
3765    }
3766
3767    #[test]
3768    fn status_str_covers_all_variants() {
3769        assert_eq!(status_str(&AgentStatus::Idle), "idle");
3770        assert_eq!(status_str(&AgentStatus::Active), "active");
3771        assert_eq!(status_str(&AgentStatus::Paused), "paused");
3772        assert_eq!(status_str(&AgentStatus::Waiting), "waiting");
3773        assert_eq!(status_str(&AgentStatus::Complete), "complete");
3774        assert_eq!(
3775            status_str(&AgentStatus::Error {
3776                message: "x".to_string()
3777            }),
3778            "error"
3779        );
3780        assert_eq!(status_str(&AgentStatus::Cancelled), "cancelled");
3781    }
3782
3783    #[tokio::test]
3784    async fn emit_events_broadcasts_agent_changes() {
3785        let mut host = host_with(vec![text("done")]);
3786        let mut rx = host.subscribe();
3787        let entity = spawn(&mut host, "run-a", "agent-a");
3788        // Attach run metadata so the `Spawned` event carries the blueprint name.
3789        host.world_mut()
3790            .world_mut()
3791            .entity_mut(entity)
3792            .insert(RunMetadata {
3793                run_id: "run-a".to_string(),
3794                agent_name: "coder".to_string(),
3795                agent_path: "/a".to_string(),
3796                task: "t".to_string(),
3797                model: None,
3798                workdir: "/w".to_string(),
3799                num_stages: 1,
3800                started_at: 0,
3801                parent_run_id: None,
3802                metadata: std::collections::HashMap::new(),
3803                callback_url: None,
3804                callback_secret: None,
3805                title: None,
3806                unattended: false,
3807                read_paths: None,
3808            });
3809
3810        // First emission after spawn: Spawned + Status + Tokens + Context.
3811        host.emit_events();
3812        let first: Vec<WorldEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
3813        assert!(
3814            first
3815                .iter()
3816                .any(|e| matches!(e, WorldEvent::Spawned { .. }))
3817        );
3818        assert!(first.iter().any(|e| matches!(e, WorldEvent::Status { .. })));
3819        assert!(first.iter().any(|e| matches!(e, WorldEvent::Tokens { .. })));
3820        assert!(
3821            first
3822                .iter()
3823                .any(|e| matches!(e, WorldEvent::Context { .. }))
3824        );
3825
3826        // A second emission with nothing changed emits nothing (skip branches).
3827        host.emit_events();
3828        assert!(rx.try_recv().is_err());
3829
3830        // Drive to completion, then emit: a terminal `Completed` fires.
3831        host.world_mut().run_until_idle(20).await;
3832        host.emit_events();
3833        let done: Vec<WorldEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
3834        assert!(
3835            done.iter()
3836                .any(|e| matches!(e, WorldEvent::Completed { .. }))
3837        );
3838
3839        // Once terminal and unchanged, a further emission fires nothing.
3840        host.emit_events();
3841        assert!(
3842            std::iter::from_fn(|| rx.try_recv().ok())
3843                .collect::<Vec<_>>()
3844                .is_empty()
3845        );
3846    }
3847
3848    #[tokio::test]
3849    async fn emit_events_unloads_terminal_agents_when_safe() {
3850        let mut host = host_with(vec![]);
3851
3852        // A terminal root: emitted on the first pass, unloaded on the second.
3853        let root = {
3854            let mut s = agent_state("root");
3855            s.status = AgentStatus::Complete;
3856            host.world.world_mut().spawn(s).id()
3857        };
3858        host.register("root", root);
3859        host.emit_events();
3860        assert!(
3861            host.live_entity("root").is_some(),
3862            "not reaped on the first terminal pass (event must go out first)"
3863        );
3864        host.emit_events();
3865        assert!(host.live_entity("root").is_none(), "reaped after emit");
3866        assert!(
3867            host.world.world().get::<AgentState>(root).is_none(),
3868            "entity despawned"
3869        );
3870
3871        // A terminal child under a LIVE (Active) parent is deferred.
3872        let parent = host.world.world_mut().spawn(agent_state("parent")).id();
3873        host.register("parent", parent);
3874        let child = {
3875            let mut s = agent_state("child");
3876            s.status = AgentStatus::Complete;
3877            host.world
3878                .world_mut()
3879                .spawn((
3880                    s,
3881                    ParentRef {
3882                        parent_entity: parent,
3883                        parent_agent_id: "parent".to_string(),
3884                        depth: 1,
3885                    },
3886                ))
3887                .id()
3888        };
3889        host.register("child", child);
3890        host.emit_events();
3891        host.emit_events();
3892        assert!(
3893            host.live_entity("child").is_some(),
3894            "not reaped while its parent is live"
3895        );
3896
3897        // Once the parent is terminal, the child becomes reapable.
3898        host.world
3899            .world_mut()
3900            .get_mut::<AgentState>(parent)
3901            .unwrap()
3902            .status = AgentStatus::Complete;
3903        host.emit_events();
3904        host.emit_events();
3905        assert!(
3906            host.live_entity("child").is_none(),
3907            "reaped once its parent is terminal"
3908        );
3909
3910        // A terminal child whose parent entity was despawned is also reapable.
3911        let ghost = host.world.world_mut().spawn_empty().id();
3912        host.world.world_mut().despawn(ghost);
3913        let orphan = {
3914            let mut s = agent_state("orphan");
3915            s.status = AgentStatus::Complete;
3916            host.world
3917                .world_mut()
3918                .spawn((
3919                    s,
3920                    ParentRef {
3921                        parent_entity: ghost,
3922                        parent_agent_id: "gone".to_string(),
3923                        depth: 1,
3924                    },
3925                ))
3926                .id()
3927        };
3928        host.register("orphan", orphan);
3929        host.emit_events();
3930        host.emit_events();
3931        assert!(
3932            host.live_entity("orphan").is_none(),
3933            "reaped: parent entity despawned"
3934        );
3935    }
3936
3937    #[tokio::test]
3938    async fn emit_events_does_not_reap_non_terminal_agents() {
3939        let mut host = host_with(vec![]);
3940        let active = host.world.world_mut().spawn(agent_state("active")).id();
3941        host.register("active", active);
3942        host.emit_events();
3943        host.emit_events();
3944        assert!(host.live_entity("active").is_some());
3945    }
3946
3947    #[tokio::test]
3948    async fn reaper_runs_once_per_agent_before_despawn() {
3949        use std::sync::atomic::{AtomicUsize, Ordering};
3950        let mut host = host_with(vec![]);
3951
3952        // The reap hook records that it saw a still-live entity, proving it runs
3953        // before despawn. A `static` counter dodges the `'static` closure bound.
3954        static SEEN_LIVE: AtomicUsize = AtomicUsize::new(0);
3955        SEEN_LIVE.store(0, Ordering::SeqCst);
3956        host.set_reaper(Box::new(|world, entity| {
3957            // Branch-free (`live as usize`) so the whole closure body is covered
3958            // by a single firing; the assertion below confirms `live` was true.
3959            let live = world.world().get::<AgentState>(entity).is_some();
3960            SEEN_LIVE.fetch_add(live as usize, Ordering::SeqCst);
3961        }));
3962
3963        let root = {
3964            let mut s = agent_state("root");
3965            s.status = AgentStatus::Complete;
3966            host.world.world_mut().spawn(s).id()
3967        };
3968        host.register("root", root);
3969        host.emit_events(); // first pass: emit terminal event, not yet reaped
3970        assert_eq!(SEEN_LIVE.load(Ordering::SeqCst), 0);
3971        host.emit_events(); // second pass: reaper fires, then despawn
3972        assert!(host.live_entity("root").is_none(), "reaped after emit");
3973        assert_eq!(
3974            SEEN_LIVE.load(Ordering::SeqCst),
3975            1,
3976            "reaper ran exactly once, while the entity was still live"
3977        );
3978    }
3979
3980    // ─── Runs that finished but are still worth reporting (issue #205) ───────
3981
3982    /// Unload `run_id` the way the daemon does: an agent that has gone terminal
3983    /// with `status`, then the two passes it takes to emit and reap it.
3984    fn unload_with(host: &mut WorldHost, run_id: &str, status: AgentStatus) {
3985        let mut s = agent_state(run_id);
3986        s.status = status;
3987        let e = host.world.world_mut().spawn(s).id();
3988        host.register(run_id, e);
3989        host.emit_events();
3990        host.emit_events();
3991    }
3992
3993    /// The failure behind issue #205: a run that died on its first inference was
3994    /// unloaded a pass later and vanished, so a scheduler polling the listing
3995    /// could not tell it from a run that had never been spawned. It now keeps
3996    /// its place, and keeps the whole error rather than the status word - which
3997    /// is the reason the row is built from the world and not from `Emitted`,
3998    /// whose `status` is a `&'static str`.
3999    #[tokio::test]
4000    async fn an_unloaded_run_stays_in_the_listing_with_the_reason_it_ended() {
4001        let mut host = host_with(vec![]);
4002        let died = AgentStatus::Error {
4003            message: "HTTP 402 Payment Required".to_string(),
4004        };
4005        unload_with(&mut host, "worker-1", died.clone());
4006
4007        assert!(host.live_entity("worker-1").is_none(), "unloaded");
4008        let listing = ask(&mut host, |reply| ControlOp::List { reply }).await;
4009        assert!(listing.runs.is_empty(), "nothing is running");
4010        assert_eq!(listing.finished.len(), 1);
4011        assert_eq!(listing.finished[0].run_id, "worker-1");
4012        assert_eq!(listing.finished[0].status, died);
4013        // A run that never persisted a snapshot still has to show an age, or the
4014        // listing answers "when did it die" with a dash.
4015        assert!(listing.finished[0].last_progress_at.is_some());
4016    }
4017
4018    /// The window is what keeps the listing from growing without end.
4019    #[tokio::test]
4020    async fn an_unloaded_run_leaves_the_listing_once_it_is_stale() {
4021        let mut host = host_with(vec![]);
4022        unload_with(&mut host, "worker-1", AgentStatus::Complete);
4023        let window = DEFAULT_FINISHED_RETENTION_SECS as i64;
4024        let at = host.finished.front().expect("just unloaded").0;
4025
4026        // Inside the window it stays...
4027        host.prune_finished(at + window);
4028        assert_eq!(host.finished().len(), 1);
4029        // ...and one second past it, it goes.
4030        host.prune_finished(at + window + 1);
4031        assert!(host.finished().is_empty());
4032    }
4033
4034    /// `0` is how an operator asks for the old behaviour back.
4035    #[tokio::test]
4036    async fn a_zero_window_keeps_nothing() {
4037        let mut host = host_with(vec![]);
4038        host.set_finished_retention_secs(0);
4039        unload_with(&mut host, "worker-1", AgentStatus::Complete);
4040
4041        assert!(host.live_entity("worker-1").is_none(), "still unloaded");
4042        assert!(host.finished().is_empty());
4043    }
4044
4045    /// However often a run is recorded, it is one row - the newest.
4046    #[tokio::test]
4047    async fn a_run_is_listed_once_however_often_it_is_recorded() {
4048        let mut host = host_with(vec![]);
4049        let entry = |status| RunListEntry {
4050            run_id: "worker-1".to_string(),
4051            status,
4052            wait_reason: None,
4053            stage: "work".to_string(),
4054            stage_index: None,
4055            num_stages: None,
4056            iteration: 0,
4057            tool_calls: 0,
4058            last_progress_at: None,
4059            unattended: false,
4060            empty_output: false,
4061            read_paths: None,
4062        };
4063        host.record_finished(entry(AgentStatus::Cancelled), 100);
4064        host.record_finished(entry(AgentStatus::Complete), 200);
4065
4066        let finished = host.finished();
4067        assert_eq!(finished.len(), 1);
4068        assert_eq!(finished[0].status, AgentStatus::Complete);
4069    }
4070
4071    /// A factory that finishes runs faster than the window empties keeps the
4072    /// most recent ones rather than growing for ever.
4073    #[tokio::test]
4074    async fn the_listing_of_finished_runs_is_capped() {
4075        let mut host = host_with(vec![]);
4076        for i in 0..=MAX_RETAINED_FINISHED {
4077            host.record_finished(
4078                RunListEntry {
4079                    run_id: format!("worker-{i}"),
4080                    status: AgentStatus::Complete,
4081                    wait_reason: None,
4082                    stage: "work".to_string(),
4083                    stage_index: None,
4084                    num_stages: None,
4085                    iteration: 0,
4086                    tool_calls: 0,
4087                    last_progress_at: None,
4088                    unattended: false,
4089                    empty_output: false,
4090                    read_paths: None,
4091                },
4092                100,
4093            );
4094        }
4095
4096        let finished = host.finished();
4097        assert_eq!(finished.len(), MAX_RETAINED_FINISHED);
4098        assert_eq!(
4099            finished[0].run_id, "worker-1",
4100            "the oldest is the one dropped"
4101        );
4102    }
4103
4104    /// The status query agrees with the listing rather than reporting no such
4105    /// run a moment after the listing still had one.
4106    #[tokio::test]
4107    async fn the_status_of_an_unloaded_run_is_still_answerable() {
4108        let mut host = host_with(vec![]);
4109        unload_with(&mut host, "worker-1", AgentStatus::Complete);
4110
4111        let status = ask(&mut host, |reply| ControlOp::Status {
4112            run_id: "worker-1".to_string(),
4113            reply,
4114        })
4115        .await;
4116        assert_eq!(status, Some(AgentStatus::Complete));
4117    }
4118
4119    /// Spawn a `Waiting` agent (optionally with an extra marker component) and
4120    /// register it under `run_id`.
4121    fn register_waiting(host: &mut WorldHost, run_id: &str) -> Entity {
4122        let mut s = agent_state(run_id);
4123        s.status = AgentStatus::Waiting;
4124        let e = host.world.world_mut().spawn(s).id();
4125        host.register(run_id, e);
4126        e
4127    }
4128
4129    /// Regression: a `Waiting` agent must NEVER be unloaded. Every `Waiting`
4130    /// state carries a live, unpersisted continuation, so flushing it to disk
4131    /// strands the run. The worst case is an agent parked on a human approval
4132    /// (`AwaitingInteraction`): unloading it means the answer has no entity to
4133    /// wake and the run hangs in "waiting" forever.
4134    #[tokio::test]
4135    async fn emit_events_never_unloads_waiting_agents() {
4136        use crate::components::AwaitingInteraction;
4137
4138        let mut host = host_with(vec![]);
4139
4140        // Parked on a human prompt (`AwaitingInteraction`) - the reported bug:
4141        // the blocked `ask` future is unpersisted, so unloading strands the run.
4142        let asking = register_waiting(&mut host, "asking");
4143        host.world
4144            .world_mut()
4145            .entity_mut(asking)
4146            .insert(AwaitingInteraction);
4147        // Gated on children, and a plain parked agent.
4148        let gated = register_waiting(&mut host, "gated");
4149        host.world
4150            .world_mut()
4151            .entity_mut(gated)
4152            .insert(WaitingForChildren);
4153        register_waiting(&mut host, "parked");
4154
4155        // Many serve passes - none of them may reap a Waiting agent.
4156        for _ in 0..5 {
4157            host.emit_events();
4158        }
4159        for run_id in ["asking", "gated", "parked"] {
4160            assert!(
4161                host.live_entity(run_id).is_some(),
4162                "a Waiting agent was unloaded and can no longer be resumed"
4163            );
4164        }
4165    }
4166
4167    #[tokio::test]
4168    async fn resolve_or_reload_pages_in_and_registers() {
4169        let mut host = host_with(vec![]);
4170        // No reloader installed → a miss stays a miss.
4171        assert!(host.resolve_or_reload("ghost").is_none());
4172
4173        // A reloader that declines (run not resumable from disk) → still a miss,
4174        // and nothing gets registered.
4175        host.set_reloader(Box::new(|_world, _run_id| None));
4176        assert!(host.resolve_or_reload("gone").is_none());
4177        assert!(
4178            host.live_entity("gone").is_none(),
4179            "a declined reload registers nothing"
4180        );
4181
4182        // With a reloader that resolves → an unloaded run is paged in and registered.
4183        host.set_reloader(Box::new(|world, run_id| {
4184            Some(world.spawn_agent((agent_state(run_id),)))
4185        }));
4186        let paged = host.resolve_or_reload("paged").expect("reloaded");
4187        assert_eq!(
4188            host.live_entity("paged"),
4189            Some(paged),
4190            "registered after reload"
4191        );
4192
4193        // A live run is returned without invoking the reloader (no re-spawn).
4194        assert_eq!(host.resolve_or_reload("paged"), Some(paged));
4195    }
4196
4197    #[tokio::test]
4198    async fn cancel_pages_in_an_unloaded_run() {
4199        let mut host = host_with(vec![]);
4200        host.set_reloader(paging_reloader());
4201        // Cancelling a run that isn't in memory pages it in, then cancels it.
4202        let cancelled = ask(&mut host, |reply| ControlOp::Cancel {
4203            run_id: "unloaded".to_string(),
4204            reply,
4205        })
4206        .await;
4207        assert!(cancelled, "reloaded then cancelled");
4208        assert_eq!(
4209            host.world
4210                .agent_status(host.live_entity("unloaded").unwrap()),
4211            Some(AgentStatus::Cancelled)
4212        );
4213    }
4214
4215    #[tokio::test]
4216    async fn emit_events_broadcasts_new_interactions_once() {
4217        let mut host = host_with(vec![]);
4218        let mut rx = host.subscribe();
4219        let backend = host.interactions().backend_for("agent-a");
4220        let asking = tokio::spawn(async move {
4221            backend
4222                .ask(leviath_core::interaction::InteractionRequest::free_text(
4223                    "q1", "p", "s", true,
4224                ))
4225                .await
4226        });
4227        for _ in 0..8 {
4228            tokio::task::yield_now().await;
4229        }
4230
4231        host.emit_events();
4232        let evs: Vec<WorldEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4233        assert!(
4234            evs.iter()
4235                .any(|e| matches!(e, WorldEvent::Interaction { .. }))
4236        );
4237        // A second emission does not re-broadcast the same interaction.
4238        host.emit_events();
4239        assert!(rx.try_recv().is_err());
4240
4241        // Answer it so the asking task finishes cleanly.
4242        assert!(
4243            host.interactions()
4244                .answer(leviath_core::interaction::InteractionResponse::text(
4245                    "q1", "ok"
4246                ))
4247        );
4248        let _ = asking.await;
4249    }
4250
4251    #[tokio::test]
4252    async fn event_sender_feeds_subscribers() {
4253        let host = host_with(vec![]);
4254        let mut rx = host.subscribe();
4255        let event = WorldEvent::Completed {
4256            run_id: "r".to_string(),
4257            agent_id: "a".to_string(),
4258            status: "complete".to_string(),
4259        };
4260        host.event_sender().send(event.clone()).unwrap();
4261        assert_eq!(rx.try_recv().unwrap(), event);
4262    }
4263
4264    #[tokio::test]
4265    async fn emit_events_skips_despawned_agents() {
4266        let mut host = host_with(vec![]);
4267        let e = spawn(&mut host, "run-a", "agent-a");
4268        host.world_mut().world_mut().despawn(e);
4269        // The stale run-id mapping is skipped; must not panic.
4270        host.emit_events();
4271    }
4272
4273    #[tokio::test]
4274    async fn serve_returns_when_control_channel_closes() {
4275        let mut host = host_with(vec![text("done")]);
4276        let (op_tx, op_rx) = mpsc::unbounded_channel();
4277        drop(op_tx); // close immediately
4278        host.serve(op_rx).await; // must return, not hang
4279    }
4280
4281    #[tokio::test]
4282    async fn mock_helpers_are_exercised() {
4283        // Keep the test mocks' non-driven methods measured (metadata, the
4284        // exhausted-infer error path, and the no-op tool exec).
4285        let p = Script {
4286            responses: Mutex::new(std::collections::VecDeque::new()),
4287        };
4288        assert_eq!(p.name(), "script");
4289        assert_eq!(p.count_tokens("t", "m").await, 1);
4290        assert_eq!(p.max_context_tokens("m"), 100_000);
4291        let _ = p.capabilities("m");
4292        let req = InferenceRequest {
4293            system: vec![],
4294            messages: vec![],
4295            model: "m".to_string(),
4296            max_tokens: 1,
4297            temperature: 0.0,
4298            tools: vec![],
4299            extra: serde_json::Value::Null,
4300            request_timeout_secs: None,
4301        };
4302        assert!(p.infer(req).await.is_err()); // exhausted
4303
4304        let exec = NoTools.exec_for(
4305            Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id"),
4306            vec![leviath_providers::ToolCall {
4307                id: "c".to_string(),
4308                name: "n".to_string(),
4309                arguments: serde_json::Value::Null,
4310                thought_signature: None,
4311            }],
4312            crate::pipeline::noop_progress(),
4313        );
4314        assert_eq!(exec().await, vec![("c".to_string(), String::new())]);
4315    }
4316
4317    #[tokio::test]
4318    async fn list_skips_despawned_entity() {
4319        let mut host = host_with(vec![]);
4320        let e = spawn(&mut host, "run-a", "agent-a");
4321        // Despawn the entity behind the world's back; the run-id map is now stale.
4322        host.world_mut().world_mut().despawn(e);
4323
4324        let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
4325        assert!(list.is_empty()); // stale mapping filtered out
4326        let status = ask(&mut host, |reply| ControlOp::Status {
4327            run_id: "run-a".to_string(),
4328            reply,
4329        })
4330        .await;
4331        assert_eq!(status, None);
4332    }
4333
4334    // ─── Wait reasons (issue #184) ───────────────────────────────────────────
4335
4336    /// Park `entity` at `Waiting` with `marker` attached, the way the engine
4337    /// would, and ask the host to explain it.
4338    fn waiting_because(
4339        host: &mut WorldHost,
4340        entity: Entity,
4341        attach: impl FnOnce(&mut bevy_ecs::world::EntityWorldMut),
4342    ) -> Option<WaitReason> {
4343        {
4344            let world = host.world_mut().world_mut();
4345            world
4346                .get_mut::<AgentState>(entity)
4347                .expect("spawned agent has state")
4348                .status = AgentStatus::Waiting;
4349            let mut e = world.entity_mut(entity);
4350            attach(&mut e);
4351        }
4352        host.wait_reason(entity)
4353    }
4354
4355    /// A run that is not waiting has nothing to explain, whatever markers it
4356    /// happens to be carrying.
4357    #[tokio::test]
4358    async fn wait_reason_is_none_unless_the_agent_is_waiting() {
4359        let mut host = host_with(vec![]);
4360        let e = spawn(&mut host, "run-a", "run-a");
4361        host.world_mut()
4362            .world_mut()
4363            .entity_mut(e)
4364            .insert(crate::pipeline::WaitingForChildren);
4365        assert_eq!(host.wait_reason(e), None);
4366    }
4367
4368    /// An entity the world no longer holds cannot be explained either.
4369    #[tokio::test]
4370    async fn wait_reason_is_none_for_an_unknown_entity() {
4371        let mut host = host_with(vec![]);
4372        let e = spawn(&mut host, "run-a", "run-a");
4373        host.world_mut().world_mut().despawn(e);
4374        assert_eq!(host.wait_reason(e), None);
4375    }
4376
4377    /// `Waiting` with nothing claiming it: report nothing rather than guess.
4378    #[tokio::test]
4379    async fn wait_reason_is_none_when_nothing_claims_the_wait() {
4380        let mut host = host_with(vec![]);
4381        let e = spawn(&mut host, "run-a", "run-a");
4382        assert_eq!(waiting_because(&mut host, e, |_| {}), None);
4383    }
4384
4385    #[tokio::test]
4386    async fn wait_reason_reports_a_taint_gate() {
4387        let mut host = host_with(vec![]);
4388        let e = spawn(&mut host, "run-a", "run-a");
4389        let reason = waiting_because(&mut host, e, |entity| {
4390            entity.insert(crate::gate_prompt::AwaitingGatePrompt(1));
4391        });
4392        assert_eq!(reason, Some(WaitReason::TaintGate));
4393    }
4394
4395    #[tokio::test]
4396    async fn wait_reason_reports_an_interaction_point() {
4397        let mut host = host_with(vec![]);
4398        let e = spawn(&mut host, "run-a", "run-a");
4399        let reason = waiting_because(&mut host, e, |entity| {
4400            entity.insert(crate::interaction_points::AwaitingInteractionPoint);
4401        });
4402        assert_eq!(reason, Some(WaitReason::InteractionPoint));
4403    }
4404
4405    /// A stage holding for sub-agents counts only the children that have not
4406    /// finished - the whole point is telling the operator how much is left.
4407    #[tokio::test]
4408    async fn wait_reason_counts_unfinished_children() {
4409        let mut host = host_with(vec![]);
4410        let parent = spawn(&mut host, "run-a", "run-a");
4411        let running = spawn(&mut host, "run-b", "run-b");
4412        let done = spawn(&mut host, "run-c", "run-c");
4413        {
4414            let world = host.world_mut().world_mut();
4415            world
4416                .get_mut::<AgentState>(done)
4417                .expect("child has state")
4418                .status = AgentStatus::Complete;
4419        }
4420        let reason = waiting_because(&mut host, parent, |entity| {
4421            entity.insert((
4422                crate::pipeline::WaitingForChildren,
4423                SubAgentChildren {
4424                    children: vec![running, done],
4425                    max_child_depth: 3,
4426                },
4427            ));
4428        });
4429        assert_eq!(reason, Some(WaitReason::Children { outstanding: 1 }));
4430    }
4431
4432    /// The marker can outlive the child list (a reload that lost them); report
4433    /// the wait rather than dropping it.
4434    #[tokio::test]
4435    async fn wait_reason_reports_children_with_none_recorded() {
4436        let mut host = host_with(vec![]);
4437        let e = spawn(&mut host, "run-a", "run-a");
4438        let reason = waiting_because(&mut host, e, |entity| {
4439            entity.insert(crate::pipeline::WaitingForChildren);
4440        });
4441        assert_eq!(reason, Some(WaitReason::Children { outstanding: 0 }));
4442    }
4443
4444    /// Open a real hub request for `agent_id` and leave it pending, returning
4445    /// the task holding it (dropping the host cancels it).
4446    fn open_prompt(
4447        host: &WorldHost,
4448        agent_id: &str,
4449        request: InteractionRequest,
4450    ) -> tokio::task::JoinHandle<InteractionResponse> {
4451        let backend = host.interactions().backend_for(agent_id.to_string());
4452        tokio::spawn(async move {
4453            use crate::dynamic_interaction::InteractionBackend;
4454            backend.ask(request).await
4455        })
4456    }
4457
4458    /// Let the spawned `ask` reach its first poll, so its request is registered
4459    /// before the assertion looks for it. `submit` inserts before it awaits, so
4460    /// yielding is enough - no sleeping, and no timeout branch to leave uncovered.
4461    async fn await_pending(host: &WorldHost, agent_id: &str) {
4462        for _ in 0..8 {
4463            tokio::task::yield_now().await;
4464        }
4465        assert!(
4466            host.interactions()
4467                .pending()
4468                .iter()
4469                .any(|(id, _)| id == agent_id),
4470            "the hub registered a request for {agent_id}"
4471        );
4472    }
4473
4474    #[tokio::test]
4475    async fn wait_reason_distinguishes_a_tool_approval_from_a_question() {
4476        let mut host = host_with(vec![]);
4477        let e = spawn(&mut host, "run-a", "run-a");
4478
4479        let approval = open_prompt(
4480            &host,
4481            "run-a",
4482            InteractionRequest::tool_approval("req-1", "shell", serde_json::json!({}), "implement"),
4483        );
4484        await_pending(&host, "run-a").await;
4485        let reason = waiting_because(&mut host, e, |entity| {
4486            entity.insert(AwaitingInteraction);
4487        });
4488        assert_eq!(reason, Some(WaitReason::ToolApproval));
4489        // Release the prompt (rather than abandoning it) so the awaiting task
4490        // finishes instead of leaking into the next case.
4491        assert_eq!(host.interactions().cancel_for_agent("run-a"), 1);
4492        approval.await.expect("the asking task finishes");
4493
4494        let question = open_prompt(
4495            &host,
4496            "run-a",
4497            InteractionRequest::free_text("req-2", "which one?", "implement", true),
4498        );
4499        await_pending(&host, "run-a").await;
4500        assert_eq!(host.wait_reason(e), Some(WaitReason::UserPrompt));
4501        assert_eq!(host.interactions().cancel_for_agent("run-a"), 1);
4502        question.await.expect("the asking task finishes");
4503    }
4504
4505    /// The marker without a matching hub entry (the request cleared in the same
4506    /// tick) still reads as a prompt rather than as nothing.
4507    #[tokio::test]
4508    async fn wait_reason_falls_back_to_user_prompt_without_a_hub_entry() {
4509        let mut host = host_with(vec![]);
4510        let e = spawn(&mut host, "run-a", "run-a");
4511        let reason = waiting_because(&mut host, e, |entity| {
4512            entity.insert(AwaitingInteraction);
4513        });
4514        assert_eq!(reason, Some(WaitReason::UserPrompt));
4515    }
4516
4517    /// A gate prompt opens a hub request of its own, so the gate-blocked agent
4518    /// carries `AwaitingInteraction` too. The specific marker has to win, or
4519    /// every gate would report as a generic prompt.
4520    #[tokio::test]
4521    async fn a_gate_outranks_the_generic_interaction_marker() {
4522        let mut host = host_with(vec![]);
4523        let e = spawn(&mut host, "run-a", "run-a");
4524        let reason = waiting_because(&mut host, e, |entity| {
4525            entity.insert((
4526                AwaitingInteraction,
4527                crate::gate_prompt::AwaitingGatePrompt(1),
4528            ));
4529        });
4530        assert_eq!(reason, Some(WaitReason::TaintGate));
4531    }
4532
4533    /// A fan-out parent reports how many workers are left, so "waiting" reads as
4534    /// progress against a denominator rather than an unexplained stall.
4535    #[tokio::test]
4536    async fn wait_reason_counts_outstanding_fan_out_workers() {
4537        let mut host = host_with(vec![]);
4538        let parent = spawn(&mut host, "run-a", "run-a");
4539        let worker = spawn(&mut host, "run-b", "run-b");
4540        {
4541            let world = host.world_mut().world_mut();
4542            world
4543                .get_mut::<AgentState>(parent)
4544                .expect("parent has state")
4545                .status = AgentStatus::Waiting;
4546            // One worker in flight and two items not yet started ⇒ three left.
4547            crate::fanout::restore_fan_out_waiting(
4548                world,
4549                parent,
4550                crate::fanout::FanOutState {
4551                    config: leviath_core::blueprint::FanOutConfig {
4552                        worker_agent: None,
4553                        worker_stage: Some("work".to_string()),
4554                        worker_query: None,
4555                        merge_stage: None,
4556                        max_workers: 2,
4557                        on_worker_failure: Default::default(),
4558                        split_prompt: String::new(),
4559                    },
4560                    max_workers: 2,
4561                    pending: vec![
4562                        crate::fanout::WorkItem::default(),
4563                        crate::fanout::WorkItem::default(),
4564                    ],
4565                    active: vec![("item-1".to_string(), "run-b".to_string())],
4566                    summaries: Vec::new(),
4567                    failures: Vec::new(),
4568                },
4569                &|run_id| (run_id == "run-b").then_some(worker),
4570            );
4571        }
4572        assert_eq!(
4573            host.wait_reason(parent),
4574            Some(WaitReason::FanOutWorkers { outstanding: 3 })
4575        );
4576    }
4577
4578    /// With run metadata attached, the listing reports the blueprint's shape and
4579    /// whether the run is unattended - an unattended run sitting on a prompt is
4580    /// the shape of a bug.
4581    #[tokio::test]
4582    async fn list_reports_blueprint_shape_and_unattended() {
4583        let mut host = host_with(vec![]);
4584        let e = spawn(&mut host, "run-a", "run-a");
4585        host.world_mut().world_mut().entity_mut(e).insert((
4586            RunMetadata {
4587                run_id: "run-a".to_string(),
4588                agent_name: "coder".to_string(),
4589                agent_path: "/tmp/agent".to_string(),
4590                task: "t".to_string(),
4591                model: None,
4592                workdir: "/tmp".to_string(),
4593                num_stages: 3,
4594                started_at: 0,
4595                parent_run_id: None,
4596                metadata: HashMap::new(),
4597                callback_url: None,
4598                callback_secret: None,
4599                title: None,
4600                unattended: true,
4601                read_paths: None,
4602            },
4603            TokenTotals {
4604                tool_calls: 9,
4605                ..Default::default()
4606            },
4607            {
4608                let mut watermark = crate::pipeline::PersistWatermark::default();
4609                watermark.backdate(1_700);
4610                watermark
4611            },
4612        ));
4613        let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
4614        assert_eq!(list[0].num_stages, Some(3));
4615        assert_eq!(list[0].tool_calls, 9);
4616        assert!(list[0].unattended);
4617        assert_eq!(list[0].last_progress_at, Some(1_700));
4618        // No outcome flags on this agent at all, so there is nothing to
4619        // report and the listing does not invent a verdict.
4620        assert!(!list[0].empty_output);
4621    }
4622
4623    /// A run that stopped having produced nothing says so in the listing -
4624    /// otherwise it is indistinguishable from one that did the work (#192).
4625    #[tokio::test]
4626    async fn list_reports_a_finished_run_that_produced_nothing() {
4627        let mut host = host_with(vec![]);
4628        let e = spawn(&mut host, "run-a", "run-a");
4629        host.world_mut()
4630            .world_mut()
4631            .entity_mut(e)
4632            .insert(crate::persistence::RunOutcomeFlags::default());
4633        // Still running: nothing to say yet.
4634        assert!(!ask(&mut host, |reply| ControlOp::List { reply }).await.runs[0].empty_output);
4635
4636        host.world_mut()
4637            .world_mut()
4638            .get_mut::<AgentState>(e)
4639            .expect("spawned agent has state")
4640            .status = AgentStatus::Complete;
4641        assert!(ask(&mut host, |reply| ControlOp::List { reply }).await.runs[0].empty_output);
4642
4643        // ...unless it never had a way to write, which is not its failing.
4644        host.world_mut()
4645            .world_mut()
4646            .get_mut::<crate::persistence::RunOutcomeFlags>(e)
4647            .expect("just inserted")
4648            .0
4649            .no_output_tools = true;
4650        assert!(!ask(&mut host, |reply| ControlOp::List { reply }).await.runs[0].empty_output);
4651    }
4652
4653    /// The listing carries the reason and the progress context, not just a word.
4654    #[tokio::test]
4655    async fn list_explains_a_waiting_run() {
4656        let mut host = host_with(vec![]);
4657        let e = spawn(&mut host, "run-a", "run-a");
4658        waiting_because(&mut host, e, |entity| {
4659            entity.insert(crate::pipeline::WaitingForChildren);
4660        });
4661        let list = ask(&mut host, |reply| ControlOp::List { reply }).await.runs;
4662        assert_eq!(list.len(), 1);
4663        assert_eq!(
4664            list[0].wait_reason,
4665            Some(WaitReason::Children { outstanding: 0 })
4666        );
4667        assert_eq!(list[0].stage_index, Some(0));
4668        // No RunMetadata on this fixture, so there is nothing to claim about the
4669        // blueprint's shape or how it was launched.
4670        assert_eq!(list[0].num_stages, None);
4671        assert!(!list[0].unattended);
4672    }
4673
4674    #[test]
4675    fn every_world_event_variant_carries_its_run_id() {
4676        let rid = "run-x".to_string();
4677        let aid = "agent-x".to_string();
4678        let events = vec![
4679            WorldEvent::Spawned {
4680                run_id: rid.clone(),
4681                agent_id: aid.clone(),
4682                blueprint: "b".to_string(),
4683            },
4684            WorldEvent::Status {
4685                run_id: rid.clone(),
4686                agent_id: aid.clone(),
4687                status: "active".to_string(),
4688                stage: "s".to_string(),
4689                iteration: 1,
4690                tool_calls: 0,
4691                accepts_messages: false,
4692            },
4693            WorldEvent::Tokens {
4694                run_id: rid.clone(),
4695                agent_id: aid.clone(),
4696                prompt_tokens: 1,
4697                completion_tokens: 2,
4698                cached_tokens: 0,
4699                cache_write_tokens: 0,
4700            },
4701            WorldEvent::Context {
4702                run_id: rid.clone(),
4703                agent_id: aid.clone(),
4704                total_tokens: 3,
4705                max_tokens: 4,
4706            },
4707            WorldEvent::Interaction {
4708                run_id: rid.clone(),
4709                agent_id: aid.clone(),
4710                request: InteractionRequest::free_text("i", "p", "s", true),
4711            },
4712            WorldEvent::Completed {
4713                run_id: rid.clone(),
4714                agent_id: aid.clone(),
4715                status: "complete".to_string(),
4716            },
4717            WorldEvent::StageTransition {
4718                run_id: rid.clone(),
4719                agent_id: aid.clone(),
4720                from: "a".to_string(),
4721                to: "b".to_string(),
4722                iteration: 1,
4723            },
4724            WorldEvent::ToolCallStarted {
4725                run_id: rid.clone(),
4726                agent_id: aid.clone(),
4727                call_id: "c".to_string(),
4728                tool: "t".to_string(),
4729            },
4730            WorldEvent::ToolCallFinished {
4731                run_id: rid.clone(),
4732                agent_id: aid.clone(),
4733                call_id: "c".to_string(),
4734                tool: "t".to_string(),
4735                ok: true,
4736                summary: "s".to_string(),
4737            },
4738            WorldEvent::Log {
4739                run_id: rid.clone(),
4740                agent_id: aid.clone(),
4741                line: "l".to_string(),
4742            },
4743        ];
4744        for ev in events {
4745            assert_eq!(ev.run_id(), "run-x");
4746        }
4747    }
4748}