Skip to main content

leviath_runtime/
world.rs

1//! The pipeline driver: a single [`PipelineWorld`] that hosts every agent as
2//! ECS data and ticks the [`crate::pipeline`] systems over all of them - the
3//! traditional-game-loop core of the shared world.
4//!
5//! The world owns the bevy [`World`], the tick [`Schedule`], the per-model
6//! inference pools, and the async bridges (inference jobs + the tool worker).
7//! Systems never block: they dispatch async work to the bridges and collect the
8//! results on a later tick. Between ticks the driver **parks** on a wake
9//! [`Notify`] until an async result lands or an external message arrives, so an
10//! idle world costs ~0 CPU regardless of how many (paused/blocked) agents it
11//! holds.
12//!
13//! ## Idle detection (no busy-spin)
14//!
15//! Each outer iteration drives the schedule to a **fixed point**: it ticks until
16//! a tick produces no change in the per-phase marker counts (the "fingerprint").
17//! At quiescence every remaining agent is either waiting on an in-flight async
18//! job (which will `notify` on completion) or blocked on a resource that only an
19//! async completion can free (a full pool) or on nothing at all (a missing
20//! provider / no input) - so the driver parks on the wake instead of spinning.
21//! A fresh async result or an external `send_message` fires the wake and the
22//! fixed-point loop re-runs.
23
24use std::sync::Arc;
25
26use bevy_ecs::prelude::*;
27use bevy_ecs::query::QueryFilter;
28use leviath_providers::ProviderError;
29use tokio::runtime::Handle;
30use tokio::sync::Notify;
31use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
32use tokio::task::JoinHandle;
33
34use crate::components::{AgentMessage, AgentState, AgentStatus};
35use crate::inference_pool::{InferencePoolConfig, InferencePools};
36use crate::persistence_bridge::persistence_worker;
37use crate::pipeline::{
38    AwaitingCompaction, AwaitingInference, AwaitingTools, AwaitingTransitionChoice,
39    AwaitingTransitionResponse, CompactionResults, InferenceResults, InferenceStage, MessageIntake,
40    PersistenceStage, ProcessResponse, Providers, ReadyForTools, ReadyForTransition, ReadyToInfer,
41    ResolveTransition, ToolResults, ToolService, ToolServiceRes, ToolStage, TransitionResults,
42    abort_terminal_work, check_workspace_health, collect_compaction, collect_inference,
43    collect_tools, collect_transition_choice, deliver_messages, detect_stuck_stage,
44    dispatch_compaction, dispatch_edge_compact, dispatch_inference, dispatch_persistence,
45    dispatch_tools, dispatch_transition_choice, enforce_max_iterations, fail_stalled_dispatch,
46    fail_wedged_runs, gate_requires_children, handle_empty_response, poll_dynamic_tool_refresh,
47    process_response, reflect_interaction_status, refresh_advertised_tools,
48    require_context_regions, resolve_transition, sync_tool_stages,
49};
50use crate::providers::ProviderRegistry;
51use crate::tool_bridge::ToolLane;
52
53/// What a tick can change, as one comparable value. Two consecutive equal
54/// fingerprints mean a tick changed nothing (quiescence).
55///
56/// Marker counts alone are not enough, because a tick can move an agent out of a
57/// marker and back into it. A stage that ends on `max_iterations` does exactly
58/// that: `enforce_max_iterations` swaps `ReadyToInfer` for `ResolveTransition` in
59/// the first chained group, and `resolve_transition` enters the next stage and
60/// re-arms `ReadyToInfer` in the second - one tick, a whole stage transition, and
61/// every count identical either side of it. The driver read that as quiescence
62/// and parked on an agent that no dispatch system had yet seen in its new stage,
63/// leaving the 30s re-drive to start the next stage (issue #197).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65struct Fingerprint {
66    /// How many agents hold each phase marker.
67    markers: [usize; 12],
68    /// Per-agent run progress that no marker reflects (see
69    /// [`PipelineWorld::agent_digest`]).
70    agents: u64,
71}
72
73/// How many attributed system panics one [`PipelineWorld::run_to_fixed_point`]
74/// round will absorb before it stops driving. Each one fails a different agent,
75/// so this only bites if the world is thoroughly broken - it exists so a
76/// pathological agent can't spin the loop.
77const MAX_TICK_FAILURES_PER_ROUND: usize = 8;
78
79/// A schedule configured the way the pipeline needs it.
80///
81/// Every pipeline system is `.chain()`ed, so the multi-threaded executor can
82/// never overlap two of them - it only adds a hop through the compute task
83/// pool. Running single-threaded keeps systems on the thread that catches their
84/// panics, which is what lets [`run_isolated`] read the offending agent out of
85/// the (thread-local) [`crate::tick_scope`].
86fn tick_schedule() -> Schedule {
87    let mut schedule = Schedule::default();
88    // bevy_ecs 0.19 replaced `set_executor_kind(ExecutorKind::…)` with
89    // `set_executor(<executor instance>)`.
90    schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
91    schedule
92}
93
94/// What one [`PipelineWorld::tick`] did.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum TickOutcome {
97    /// Every system ran to completion.
98    Clean,
99    /// A system panicked and the agent responsible was failed; the rest of the
100    /// world is unaffected and can keep being driven.
101    AgentFailed,
102    /// A system panicked with no agent in scope, so nothing could be failed.
103    /// Re-ticking would just re-panic.
104    Unattributed,
105}
106
107/// How many agents are in each status. See [`PipelineWorld::lane_snapshot`].
108#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub struct AgentCounts {
110    /// Doing work, or ready to.
111    pub active: usize,
112    /// Blocked on input, a child, or a prompt.
113    pub waiting: usize,
114    /// Parked by the user.
115    pub paused: usize,
116    /// Spawned but not yet started.
117    pub idle: usize,
118    /// Finished, still loaded pending reaping.
119    pub terminal: usize,
120}
121
122impl std::fmt::Display for AgentCounts {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(
125            f,
126            "active={} waiting={} paused={} idle={} terminal={}",
127            self.active, self.waiting, self.paused, self.idle, self.terminal
128        )
129    }
130}
131
132/// What the world is holding and what it is waiting on, at one instant.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct LaneSnapshot {
135    /// Loaded agents by status.
136    pub agents: AgentCounts,
137    /// Inference-pool occupancy, one entry per model actually used.
138    pub inference: Vec<crate::inference_pool::PoolOccupancy>,
139    /// Tool batches holding lane capacity and running.
140    pub tools_busy: usize,
141    /// Tool batches waiting for lane capacity.
142    pub tools_queued: usize,
143    /// Tool batches parked on an unbounded wait, holding no capacity.
144    pub tools_parked: usize,
145    /// The tool lane's concurrency cap.
146    pub tools_workers: usize,
147    /// The lane full with batches still queued behind it.
148    pub tools_saturated: bool,
149}
150
151impl LaneSnapshot {
152    /// Whether some lane is at capacity with work queued behind it - the shape
153    /// worth raising the log level for.
154    #[must_use]
155    pub fn is_under_pressure(&self) -> bool {
156        self.tools_saturated
157            || (self.agents.active > 0 && self.inference.iter().any(|p| p.is_full()))
158    }
159
160    /// The per-model inference occupancy, rendered for a log line.
161    #[must_use]
162    pub fn inference_summary(&self) -> String {
163        if self.inference.is_empty() {
164            return "none".to_string();
165        }
166        self.inference
167            .iter()
168            .map(ToString::to_string)
169            .collect::<Vec<_>>()
170            .join(" ")
171    }
172}
173
174/// The shared ECS world that hosts and drives every agent.
175pub struct PipelineWorld {
176    world: World,
177    schedule: Schedule,
178    wake: Arc<Notify>,
179    shutdown: Arc<Notify>,
180    msg_tx: UnboundedSender<AgentMessage>,
181    /// The tool lane, kept so the world can widen it under relief.
182    tool_lane: Arc<ToolLane>,
183    /// The task serving the tool lane; kept so it lives as long as the world. It
184    /// exits on its own once the world (and thus the [`ToolStage`] sender) is
185    /// dropped and the batches it started have finished.
186    _tool_task: JoinHandle<()>,
187    /// The persistence worker task. Retained (rather than detached) so
188    /// [`Self::flush_and_stop`] can close its channel and `await` it, guaranteeing
189    /// every queued snapshot reaches disk before shutdown. `None` once flushed.
190    persist_task: Option<JoinHandle<()>>,
191}
192
193impl PipelineWorld {
194    /// Build a world: wire the pool/bridge resources, register the providers and
195    /// tool service, spawn the tool worker onto `runtime`, and assemble the tick
196    /// schedule. Agents are added later via [`Self::spawn_agent`].
197    ///
198    /// `runs_dir` is where agent snapshots persist (`<runs_dir>/<run_id>/`, the
199    /// daemon's on-disk layout). `None` keeps the world entirely in memory:
200    /// snapshots are still produced and drained (so log events and watermarks
201    /// behave identically) but nothing is ever written to disk.
202    pub fn new(
203        providers: ProviderRegistry,
204        tool_service: Arc<dyn ToolService>,
205        pool_config: InferencePoolConfig,
206        tool_concurrency: usize,
207        runs_dir: Option<std::path::PathBuf>,
208        runtime: Handle,
209    ) -> Self {
210        // `Query::par_iter` fans out over the compute task pool; initialize it
211        // once (idempotent) so per-agent request assembly in `dispatch_inference`
212        // runs in parallel. (The schedule executor itself is single-threaded -
213        // see `tick_schedule`.)
214        bevy_tasks::ComputeTaskPool::get_or_init(bevy_tasks::TaskPool::default);
215
216        let wake = Arc::new(Notify::new());
217        let shutdown = Arc::new(Notify::new());
218
219        let (inf_tx, inf_rx) = unbounded_channel();
220        let (trans_tx, trans_rx) = unbounded_channel();
221        let (compact_tx, compact_rx) = unbounded_channel();
222        let (tool_job_tx, tool_job_rx) = unbounded_channel();
223        let (tool_res_tx, tool_res_rx) = unbounded_channel();
224        let (persist_tx, persist_rx) = unbounded_channel();
225        let (msg_tx, msg_rx) = unbounded_channel();
226        let (ip_tx, ip_rx) = unbounded_channel();
227        let (gp_tx, gp_rx) = unbounded_channel();
228        let (cs_tx, cs_rx) = unbounded_channel();
229        let (title_tx, title_rx) = unbounded_channel();
230
231        let tool_stats = Arc::new(crate::tool_bridge::ToolLaneStats::new(tool_concurrency));
232        let tool_lane = ToolLane::new(
233            runtime.clone(),
234            tool_res_tx,
235            wake.clone(),
236            tool_concurrency,
237            tool_stats.clone(),
238        );
239        let tool_task = tool_lane.serve(tool_job_rx);
240        // Retained so `flush_and_stop` can drain it on shutdown. Left to its own
241        // devices otherwise: it exits when the world (and thus its PersistenceStage
242        // sender) is dropped.
243        let persist_task = runtime.spawn(persistence_worker(runs_dir, persist_rx));
244        let ip_runtime = runtime.clone();
245        let gp_runtime = runtime.clone();
246
247        let mut world = World::new();
248        world.insert_resource(Providers(providers));
249        world.insert_resource(InferenceStage {
250            // The wake goes into the pools, not just the bridges: freeing a slot
251            // has to re-drive dispatch, or the agents parked on a full pool never
252            // learn that capacity came back (issue #189).
253            pools: Arc::new(InferencePools::new(pool_config).with_wake(wake.clone())),
254            outcomes: inf_tx,
255            transition_outcomes: trans_tx,
256            compaction_outcomes: compact_tx,
257            content_summary_outcomes: cs_tx,
258            wake: wake.clone(),
259            runtime,
260            exact_token_counting: false,
261        });
262        world.insert_resource(crate::context_transform::ContentSummaryResults(cs_rx));
263        world.insert_resource(crate::title::TitleSink(title_tx));
264        world.insert_resource(crate::title::TitleResults(title_rx));
265        world.insert_resource(crate::interaction_points::InteractionPointStage {
266            outcomes: ip_tx,
267            wake: wake.clone(),
268            runtime: ip_runtime,
269        });
270        world.insert_resource(crate::interaction_points::InteractionPointResults(ip_rx));
271        world.insert_resource(crate::gate_prompt::GatePromptStage {
272            outcomes: gp_tx,
273            wake: wake.clone(),
274            runtime: gp_runtime,
275        });
276        world.insert_resource(crate::gate_prompt::GatePromptResults(gp_rx));
277        world.insert_resource(InferenceResults(inf_rx));
278        world.insert_resource(TransitionResults(trans_rx));
279        world.insert_resource(CompactionResults(compact_rx));
280        world.insert_resource(ToolServiceRes(tool_service));
281        world.insert_resource(ToolStage::new(tool_job_tx, tool_stats));
282        world.insert_resource(ToolResults(tool_res_rx));
283        world.insert_resource(PersistenceStage(persist_tx));
284        world.insert_resource(MessageIntake(msg_rx));
285        // Telemetry defaults to the no-op sink; a host that wants export
286        // replaces the resource after construction (as `build_host` does).
287        world.insert_resource(crate::telemetry::Telemetry(std::sync::Arc::new(
288            leviath_core::telemetry::NoopSink,
289        )));
290
291        // The tick chain is split into two `.chain()`ed groups (bevy caps a
292        // system tuple at 20); the second group runs strictly after the first.
293        let mut schedule = tick_schedule();
294        schedule.add_systems(
295            (
296                // First: stop whatever a now-terminal agent still has running in
297                // the async lanes. Ahead of everything else so a cancel frees its
298                // inference permit and tool-lane capacity on the very next tick,
299                // rather than whenever the provider or tool happens to answer.
300                abort_terminal_work,
301                deliver_messages,
302                collect_compaction,
303                // Apply any completed Summarize context-transform summaries into
304                // the child's regions, then dispatch newly-queued ones.
305                crate::context_transform::collect_content_summary,
306                crate::context_transform::dispatch_content_summary,
307                // Route edge-transform compaction through the compaction lane
308                // before the threshold-based pass.
309                dispatch_edge_compact,
310                dispatch_compaction,
311                // Cap a stage at its max_iterations before running more inference.
312                enforce_max_iterations,
313                // …then the softer guard: bail out of a stage that is burning
314                // turns/edits without progress, when the blueprint declares a
315                // `stuck` escape edge. Runs after the hard cap so that always wins.
316                detect_stuck_stage,
317                // Stop a run whose working directory vanished, rather than let
318                // every tool fail with ENOENT for the rest of the run.
319                check_workspace_health,
320                // Tag dynamic_tools agents that have pending tool changes, then
321                // apply the re-advertisement before the next request is assembled
322                // so a newly-discovered tool is visible.
323                poll_dynamic_tool_refresh,
324                refresh_advertised_tools,
325                // Move ready agents off any provider whose circuit is open, so
326                // dispatch only ever considers one still in service. Serial,
327                // because it needs `&mut StageInference` and dispatch fans out.
328                // Nested rather than inline: the outer tuple is at bevy's
329                // 20-system limit for `.chain()`.
330                (crate::pipeline::rotate_open_circuits, dispatch_inference).chain(),
331                collect_inference,
332                // Intercept a fan-out stage's split response before normal routing.
333                crate::fanout::fan_out_split,
334                process_response,
335                // Apply resolved taint gate prompts (re-arming ReadyForTools)
336                // before the tool dispatch re-runs the held batch.
337                crate::gate_prompt::collect_gate_prompt,
338                dispatch_tools,
339                collect_tools,
340                // Apply any resolved stage-boundary interaction-point answers
341                // before the stage decides its transition.
342                crate::interaction_points::collect_interaction_point,
343            )
344                .chain(),
345        );
346        schedule.add_systems(
347            (
348                handle_empty_response,
349                // Hold a `requires_children` stage until its sub-agents finish.
350                gate_requires_children,
351                // Re-run a stage that left a `required` context region empty
352                // before it may transition or ask for approval.
353                require_context_regions,
354                // Intercept a would-be transition for an interactive-points stage
355                // (e.g. plan_approval) and drive the interaction-point lane.
356                crate::interaction_points::gate_interaction_points,
357                crate::interaction_points::dispatch_interaction_point,
358                resolve_transition,
359                dispatch_transition_choice,
360                collect_transition_choice,
361                // Drive fan-out workers and merge once they finish.
362                crate::fanout::fan_out_collect,
363                // Narrate lifecycle/activity into the telemetry sink. Must run
364                // before `sync_tool_stages` (which consumes the transient
365                // `StageJustEntered` marker) and before `dispatch_persistence`
366                // (which drains the log buffer this system only reads).
367                crate::telemetry::observe_lifecycle,
368                sync_tool_stages,
369                // Store any finished run title, then start newly-marked ones.
370                // Collect precedes persistence so a landed title is written on
371                // this same tick.
372                crate::title::collect_title,
373                crate::title::dispatch_title,
374                // Fail a run whose dispatch has been declining for something
375                // that will never arrive. Last of the guards, and after *both*
376                // dispatch systems, so it reads stall records both lanes have
377                // refreshed on this same tick - and before persistence, so the
378                // failure reaches disk immediately.
379                fail_stalled_dispatch,
380                // Mirror open interaction-hub requests into agent status
381                // (Active ↔ Waiting) so the dashboard surfaces blocked prompts;
382                // must run before persistence so the status change is written.
383                reflect_interaction_status,
384                // Fail a run nothing can drive at all. After every dispatch and
385                // collect system, so a marker set anywhere on this tick counts;
386                // after the interaction reflection, so an agent that just parked
387                // on a prompt is already wearing its marker and is exempt; and
388                // before persistence, so the failure reaches meta.json on the
389                // same tick rather than waiting for the next one.
390                fail_wedged_runs,
391                dispatch_persistence,
392            )
393                .chain()
394                .after(crate::interaction_points::collect_interaction_point),
395        );
396
397        Self {
398            world,
399            schedule,
400            wake,
401            shutdown,
402            msg_tx,
403            tool_lane,
404            _tool_task: tool_task,
405            persist_task: Some(persist_task),
406        }
407    }
408
409    /// Mutable access to the underlying ECS world, for spawning agents (the CLI /
410    /// daemon builds each agent's component bundle) and inspection.
411    ///
412    /// This is the unstable layer: it exposes raw `bevy_ecs` (re-exported as
413    /// [`crate::ecs`] so versions stay aligned) and carries no compatibility
414    /// promise across releases. Prefer [`crate::AgentWorld`] or
415    /// [`crate::host::WorldHost`] unless you are building your own assembly.
416    pub fn world_mut(&mut self) -> &mut World {
417        &mut self.world
418    }
419
420    /// Read-only access to the underlying ECS world.
421    pub fn world(&self) -> &World {
422        &self.world
423    }
424
425    /// Enable (or disable) the opt-in exact pre-inference budget guard for this
426    /// world - see `inference_bridge::InferenceJob::exact_token_counting`.
427    /// Call once at startup when the run config requests it, before serving.
428    pub fn set_exact_token_counting(&mut self, enabled: bool) {
429        // `InferenceStage` is inserted by every `PipelineWorld::new` path, so it
430        // is a hard invariant here - `resource_mut` (which panics if absent) is
431        // correct and keeps this branch-free.
432        self.world
433            .resource_mut::<crate::pipeline::InferenceStage>()
434            .exact_token_counting = enabled;
435    }
436
437    /// Install the shared interaction hub as a world resource and attach this
438    /// world's wake handle to it, so opening/answering a prompt wakes the driver
439    /// and [`reflect_interaction_status`]
440    /// mirrors the change into agent status. Call once at startup, before
441    /// serving. Without this, that system is a no-op (test worlds).
442    pub fn insert_interaction_hub(&mut self, hub: crate::interaction_hub::InteractionHub) {
443        hub.attach_wake(self.wake.clone());
444        self.world.insert_resource(hub);
445    }
446
447    /// Spawn an agent from its pre-built component bundle and wake the driver so
448    /// the next fixed-point picks it up. Returns the new entity.
449    pub fn spawn_agent(&mut self, bundle: impl Bundle) -> Entity {
450        let e = self.world.spawn(bundle).id();
451        self.wake.notify_one();
452        e
453    }
454
455    /// Spawn an agent from a blueprint + task + per-stage resolution (see
456    /// [`crate::pipeline::spawn_agent`]) and wake the driver. Returns the new
457    /// entity, or an error if the first stage's system prompt doesn't fit.
458    pub fn spawn_from_blueprint(
459        &mut self,
460        agent_id: String,
461        blueprint: leviath_core::Blueprint,
462        task: &str,
463        stages: Vec<crate::pipeline::ResolvedStage>,
464        global_hints: leviath_core::config::PromptHints,
465    ) -> Result<Entity, String> {
466        let e = crate::pipeline::spawn_agent(
467            &mut self.world,
468            agent_id,
469            blueprint,
470            task,
471            stages,
472            global_hints,
473        )?;
474        self.wake.notify_one();
475        Ok(e)
476    }
477
478    /// Deliver a message to a running agent (routed to its inbox on the next
479    /// tick) and wake the driver.
480    pub fn send_message(&self, msg: AgentMessage) -> Result<(), ProviderError> {
481        self.msg_tx
482            .send(msg)
483            .map_err(|e| ProviderError::Other(format!("world message channel closed: {e}")))?;
484        self.wake.notify_one();
485        Ok(())
486    }
487
488    /// A clone of the wake handle, so external producers (e.g. a control socket)
489    /// can nudge the driver after mutating the world directly.
490    pub fn wake_handle(&self) -> Arc<Notify> {
491        self.wake.clone()
492    }
493
494    /// Request the [`Self::run`] loop to stop after its current fixed point.
495    pub fn shutdown(&self) {
496        self.shutdown.notify_one();
497    }
498
499    /// A clone of the shutdown handle, so a supervisor can stop a [`Self::run`]
500    /// loop that has taken ownership of the world on another task.
501    pub fn shutdown_handle(&self) -> Arc<Notify> {
502        self.shutdown.clone()
503    }
504
505    /// Cleanly stop the world, guaranteeing every queued snapshot reaches disk.
506    ///
507    /// The persistence lane is async and fire-and-forget, so a plain shutdown (the
508    /// [`Self::run`]/`serve` loop returning, then the world dropping) can lose
509    /// snapshots still queued in the channel. This method closes that gap: it
510    /// signals shutdown, drives one last fixed point so any state that settled
511    /// after the loop parked is dispatched to the lane, then **closes the lane and
512    /// awaits the worker** so all queued writes (`meta.json` / `context.json` /
513    /// `run.lvr`) land before it returns.
514    ///
515    /// Call it after the serve loop has returned (the tokio runtime must still be
516    /// alive for the worker to be scheduled). Idempotent: a second call is a no-op
517    /// because the persistence resource is already removed and the task taken.
518    pub async fn flush_and_stop(&mut self) {
519        // Idempotent - the serve loop has usually already returned on this signal.
520        self.shutdown.notify_one();
521        // Dispatch anything that settled between the last park and now (e.g. an
522        // inference result that woke the loop the same instant shutdown fired).
523        self.run_to_fixed_point();
524        // Drop the *only* `PersistJob` sender so the worker's `recv()` loop drains
525        // its queue and then ends.
526        self.world.remove_resource::<PersistenceStage>();
527        // Wait for every queued write to hit disk.
528        if let Some(task) = self.persist_task.take() {
529            let _ = task.await;
530        }
531        // Push any buffered telemetry export out before the process goes away;
532        // the final fixed point above already emitted the last events. The
533        // resource always exists - `new()` installs the no-op default.
534        self.world
535            .resource::<crate::telemetry::Telemetry>()
536            .0
537            .force_flush();
538    }
539
540    /// A point-in-time read of what the world is holding and what it is waiting
541    /// on: agents by status, per-model inference-pool occupancy, and tool-lane
542    /// occupancy.
543    ///
544    /// Providers currently taken out of service by their circuit breaker.
545    ///
546    /// Empty when the breaker is not installed, so an embedded world that never
547    /// inserted the resource simply reports nothing wrong (issue #201).
548    pub fn open_circuits(&self) -> Vec<crate::pipeline::ProviderCircuitState> {
549        let Some(circuits) = self
550            .world
551            .get_resource::<crate::pipeline::ProviderCircuits>()
552        else {
553            return Vec::new();
554        };
555        let policy = self
556            .world
557            .get_resource::<crate::pipeline::CircuitPolicy>()
558            .copied()
559            .unwrap_or_default();
560        circuits.open_circuits(chrono::Utc::now().timestamp(), &policy)
561    }
562
563    /// This is the answer to "the daemon has been quiet for hours - is anything
564    /// actually running?", which issue #189 had no way to ask.
565    pub fn lane_snapshot(&self) -> LaneSnapshot {
566        let mut agents = AgentCounts::default();
567        for state in self
568            .world
569            .iter_entities()
570            .filter_map(|e| e.get::<AgentState>())
571        {
572            match state.status {
573                AgentStatus::Active => agents.active += 1,
574                AgentStatus::Waiting => agents.waiting += 1,
575                AgentStatus::Paused => agents.paused += 1,
576                AgentStatus::Idle => agents.idle += 1,
577                // Terminal agents linger until the reaper unloads them; counting
578                // them apart keeps "nothing is running" honest.
579                AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled => {
580                    agents.terminal += 1
581                }
582            }
583        }
584        let tools = self.world.resource::<ToolStage>().stats.clone();
585        LaneSnapshot {
586            agents,
587            inference: self.world.resource::<InferenceStage>().pools.occupancy(),
588            tools_busy: tools.busy(),
589            tools_queued: tools.queued(),
590            tools_parked: tools.parked(),
591            tools_workers: tools.workers(),
592            tools_saturated: tools.is_saturated(),
593        }
594    }
595
596    /// Widen the tool lane by `extra` batches, permanently.
597    ///
598    /// The relief valve: when the lane has stopped draining, handing out more
599    /// capacity lets the queued batches through without cancelling anything.
600    /// Returns how many were added.
601    pub fn relieve_tool_lane(&self, extra: usize) -> usize {
602        self.tool_lane.relieve(extra)
603    }
604
605    /// The status of an agent, if it still exists.
606    pub fn agent_status(&self, entity: Entity) -> Option<AgentStatus> {
607        self.world
608            .get::<AgentState>(entity)
609            .map(|s| s.status.clone())
610    }
611
612    /// Set an agent's status and wake the driver. Returns `false` if the agent no
613    /// longer exists. The async-starting dispatchers only act on `Active` agents,
614    /// so this is how the world pauses/resumes/cancels an agent - a non-`Active`
615    /// agent is simply data the systems skip until it is `Active` again.
616    pub fn set_status(&mut self, entity: Entity, status: AgentStatus) -> bool {
617        let Some(mut state) = self.world.get_mut::<AgentState>(entity) else {
618            return false;
619        };
620        state.status = status;
621        self.wake.notify_one();
622        true
623    }
624
625    /// Pause an agent (it finishes any in-flight step, then stops before starting
626    /// new work). Only `Active` and `Idle` agents can be paused: a `Waiting`
627    /// agent's status is the marker the fan-out merge poll and interaction
628    /// resolution depend on, so overwriting it would wedge the run, and pausing
629    /// a terminal agent is meaningless. Returns `false` if the agent no longer
630    /// exists or is not in a pausable state.
631    pub fn pause(&mut self, entity: Entity) -> bool {
632        match self.agent_status(entity) {
633            Some(AgentStatus::Active | AgentStatus::Idle) => {
634                self.set_status(entity, AgentStatus::Paused)
635            }
636            _ => false,
637        }
638    }
639
640    /// Resume a paused agent. `Idle` is also accepted (resume-as-nudge for an
641    /// agent that has not ticked yet); anything else returns `false`.
642    pub fn resume(&mut self, entity: Entity) -> bool {
643        match self.agent_status(entity) {
644            Some(AgentStatus::Paused | AgentStatus::Idle) => {
645                self.set_status(entity, AgentStatus::Active)
646            }
647            _ => false,
648        }
649    }
650
651    /// Cancel an agent (it stops starting new work; in-flight results still land).
652    pub fn cancel(&mut self, entity: Entity) -> bool {
653        self.set_status(entity, AgentStatus::Cancelled)
654    }
655
656    /// Run one schedule tick over every agent, catching a panic from any system
657    /// so one bad agent can't crash the daemon and take every other hosted agent
658    /// with it.
659    ///
660    /// When the panic can be traced to a specific agent (the usual case - see
661    /// `tick_scope`), that agent is failed with the panic message so it
662    /// stops being driven, its run is persisted as errored, and the host reaps
663    /// it. Without that, the world would re-tick the same unchanged state on
664    /// every wake and panic again indefinitely.
665    pub fn tick(&mut self) -> TickOutcome {
666        let Err(panicked) = run_isolated(&mut self.schedule, &mut self.world) else {
667            // A clean unwind doesn't mean a clean tick: work that ran on the
668            // compute pool catches its own panics, since they can't unwind back
669            // here, and leaves a marker instead.
670            return self.fail_agents_panicked_in_parallel();
671        };
672        let message = panic_status_message(&panicked.message);
673        match panicked.entity {
674            Some(entity) if self.set_status(entity, AgentStatus::Error { message }) => {
675                tracing::error!(
676                    ?entity,
677                    panic = %panicked.message,
678                    "a pipeline system panicked; failing that agent - the daemon and every \
679                     other run keep going"
680                );
681                TickOutcome::AgentFailed
682            }
683            _ => {
684                tracing::error!(
685                    panic = %panicked.message,
686                    "a pipeline system panicked outside any agent's scope; the daemon survived \
687                     (an agent may be wedged - cancel it via `lev cancel <run-id>`)"
688                );
689                TickOutcome::Unattributed
690            }
691        }
692    }
693
694    /// Fail every agent that a compute-pool body marked
695    /// [`PanickedInParallel`](crate::tick_scope::PanickedInParallel), and report
696    /// whether there were any.
697    ///
698    /// These panics were caught on a task-pool thread rather than unwinding into
699    /// `tick`, so the marker component is how they reach the driver - but from
700    /// here on they are handled exactly like an attributed unwind: the agent is
701    /// failed, stops being driven, and its run persists as errored.
702    fn fail_agents_panicked_in_parallel(&mut self) -> TickOutcome {
703        let mut query = self
704            .world
705            .query::<(Entity, &crate::tick_scope::PanickedInParallel)>();
706        let failed: Vec<(Entity, String)> = query
707            .iter(&self.world)
708            .map(|(entity, p)| (entity, p.message.clone()))
709            .collect();
710        if failed.is_empty() {
711            return TickOutcome::Clean;
712        }
713        for (entity, message) in failed {
714            self.world
715                .entity_mut(entity)
716                .remove::<crate::tick_scope::PanickedInParallel>();
717            let status = AgentStatus::Error {
718                message: panic_status_message(&message),
719            };
720            // The entity came straight out of the query above, so it exists.
721            let _ = self.set_status(entity, status);
722        }
723        TickOutcome::AgentFailed
724    }
725
726    /// Append a system to the schedule (test-only, for panic-isolation tests).
727    #[cfg(test)]
728    pub(crate) fn add_test_system<M>(
729        &mut self,
730        // `IntoSystemConfigs` became `IntoScheduleConfigs<ScheduleSystem, _>` in
731        // bevy_ecs 0.19 (it now also describes observer and other schedulables,
732        // so the schedulable kind is an explicit parameter).
733        system: impl bevy_ecs::schedule::IntoScheduleConfigs<bevy_ecs::system::ScheduleSystem, M>,
734    ) {
735        self.schedule.add_systems(system);
736    }
737
738    fn count<F: QueryFilter>(&mut self) -> usize {
739        let mut q = self.world.query_filtered::<(), F>();
740        q.iter(&self.world).count()
741    }
742
743    /// Digest the run progress a phase marker cannot show: each agent's status,
744    /// which stage it is in, and its per-stage counters.
745    ///
746    /// Only values that step on a real event go in. Anything that moves on its
747    /// own (a clock, a stall timestamp) would keep the fixed-point loop from ever
748    /// converging, which is a spinning daemon rather than a parked one.
749    ///
750    /// The per-agent digests are XOR-folded, so archetype iteration order doesn't
751    /// matter; each one includes the entity id so two agents swapping states
752    /// can't cancel out.
753    fn agent_digest(&mut self) -> u64 {
754        use std::hash::{Hash, Hasher};
755        let mut query = self.world.query::<(
756            Entity,
757            &AgentState,
758            Option<&crate::pipeline::StageCursor>,
759            Option<&crate::pipeline::StageProgress>,
760        )>();
761        query
762            .iter(&self.world)
763            .map(|(entity, state, cursor, progress)| {
764                let mut hasher = std::collections::hash_map::DefaultHasher::new();
765                entity.to_bits().hash(&mut hasher);
766                state.status.hash(&mut hasher);
767                state.current_stage.hash(&mut hasher);
768                state.iteration.hash(&mut hasher);
769                cursor.map(|c| c.index).hash(&mut hasher);
770                progress
771                    .map(|p| {
772                        (
773                            p.iterations,
774                            p.total_tool_calls,
775                            p.modifying_tool_calls,
776                            p.gate_reentries,
777                            p.stuck_fired,
778                        )
779                    })
780                    .hash(&mut hasher);
781                hasher.finish()
782            })
783            .fold(0, |acc, digest| acc ^ digest)
784    }
785
786    /// Snapshot the per-phase marker counts and the per-agent progress digest.
787    fn fingerprint(&mut self) -> Fingerprint {
788        let markers = [
789            self.count::<With<ReadyToInfer>>(),
790            self.count::<With<AwaitingInference>>(),
791            self.count::<With<ProcessResponse>>(),
792            self.count::<With<ReadyForTools>>(),
793            self.count::<With<ReadyForTransition>>(),
794            self.count::<With<ResolveTransition>>(),
795            self.count::<With<AwaitingTools>>(),
796            self.count::<With<AwaitingTransitionChoice>>(),
797            self.count::<With<AwaitingTransitionResponse>>(),
798            self.count::<With<AwaitingCompaction>>(),
799            self.count::<With<crate::title::PendingTitle>>(),
800            self.count::<With<crate::title::AwaitingTitle>>(),
801        ];
802        Fingerprint {
803            markers,
804            agents: self.agent_digest(),
805        }
806    }
807
808    /// Any agent waiting on an in-flight async job (inference, tools, a
809    /// transition choice, or compaction) whose completion will wake the driver.
810    fn has_async_inflight(&mut self) -> bool {
811        self.count::<With<AwaitingInference>>() > 0
812            || self.count::<With<AwaitingTools>>() > 0
813            || self.count::<With<AwaitingTransitionResponse>>() > 0
814            || self.count::<With<AwaitingCompaction>>() > 0
815            || self.count::<With<crate::title::AwaitingTitle>>() > 0
816    }
817
818    /// Drive the schedule until a tick changes nothing (quiescence). Public so a
819    /// host loop can interleave control operations between quiescent points.
820    pub fn run_to_fixed_point(&mut self) {
821        let mut prev = self.fingerprint();
822        let mut failures = 0;
823        loop {
824            let outcome = self.tick();
825            match outcome {
826                TickOutcome::Clean => {}
827                // The offending agent has been failed, so it won't be driven
828                // again. Keep ticking: the rest of the world still has work to
829                // do, and only a later tick reaches `dispatch_persistence` (the
830                // last system in the chain) to record the failure on disk. The
831                // budget stops a pathological agent that somehow panics again
832                // from spinning this loop.
833                TickOutcome::AgentFailed if failures < MAX_TICK_FAILURES_PER_ROUND => {
834                    failures += 1;
835                }
836                // Nothing to fail, so re-ticking would just re-panic: stop
837                // driving this round. The daemon stays alive, other agents keep
838                // running, and a wedged agent can be cancelled via the control
839                // socket (dispatch systems skip non-Active agents once
840                // cancelled).
841                TickOutcome::AgentFailed | TickOutcome::Unattributed => break,
842            }
843            let now = self.fingerprint();
844            // Quiescence, but only trust it after a clean tick: a panicking tick
845            // abandons the rest of the chain (and its buffered commands), so the
846            // markers can look unchanged while the world very much has changed.
847            // Force at least one more tick so the failed agent gets persisted.
848            if now == prev && outcome == TickOutcome::Clean {
849                break;
850            }
851            prev = now;
852        }
853    }
854
855    /// Drive every agent as far as it can go **right now**, then, while async
856    /// work is in flight, wait for each completion and drive again - returning
857    /// once the world is fully quiescent with nothing in flight. Bounded by
858    /// `max_waits` wake-waits as a safety valve so a lost/never-arriving wake
859    /// can't hang a caller (e.g. a test) forever.
860    pub async fn run_until_idle(&mut self, max_waits: usize) {
861        self.run_to_fixed_point();
862        let mut waits = 0;
863        while self.has_async_inflight() && waits < max_waits {
864            self.wake.notified().await;
865            waits += 1;
866            self.run_to_fixed_point();
867        }
868    }
869
870    /// Run forever: drive to quiescence, then park until an async completion or
871    /// an external `send_message`/`spawn_agent` wakes the driver. Returns when
872    /// [`Self::shutdown`] is signalled.
873    pub async fn run(&mut self) {
874        loop {
875            self.run_to_fixed_point();
876            tokio::select! {
877                _ = self.wake.notified() => {}
878                _ = self.shutdown.notified() => return,
879            }
880        }
881    }
882}
883
884/// How a caught panic is recorded on the agent it is blamed on. Shared by the
885/// unwind path and the compute-pool path so a run's `error` reads the same
886/// either way.
887fn panic_status_message(panic: &str) -> String {
888    format!("internal error: a pipeline system panicked: {panic}")
889}
890
891/// A panic caught while ticking the schedule, and the agent it belongs to.
892struct TickPanic {
893    /// The agent being processed when the panic fired, if the pipeline had
894    /// recorded one (see [`crate::tick_scope`]).
895    entity: Option<Entity>,
896    /// The panic payload rendered as text.
897    message: String,
898}
899
900/// Run a schedule over a world, catching a panic from any system so it can't
901/// unwind the daemon's drive loop and take down every hosted agent.
902///
903/// The world may be partially updated after a panic: the panicking system's
904/// buffered `Commands` are lost, but resources and components already written
905/// are intact, so the caller can still fail the offending agent.
906fn run_isolated(schedule: &mut Schedule, world: &mut World) -> Result<(), TickPanic> {
907    // Clear first: the slot is thread-local and survives across ticks, so a
908    // stale entity from an earlier tick must not be blamed for this one.
909    crate::tick_scope::clear();
910    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| schedule.run(world))) {
911        Ok(()) => Ok(()),
912        Err(payload) => {
913            reset_executor(schedule);
914            Err(TickPanic {
915                entity: crate::tick_scope::current(),
916                message: leviath_core::panic_message(payload.as_ref()),
917            })
918        }
919    }
920}
921
922/// Give `schedule` a fresh executor after a caught panic.
923///
924/// bevy's executors mark a system "completed" *before* running it and only
925/// clear that set when `run` returns normally. A panic therefore leaves every
926/// system up to and including the offending one marked done, so the **next**
927/// tick silently skips them and only runs the tail of the chain - a partial
928/// tick that would, among other things, keep `dispatch_persistence` from ever
929/// seeing an agent we just failed. Swapping the executor kind and back is the
930/// public API for forcing a rebuild.
931///
932/// One call suffices on bevy_ecs 0.19: `set_executor` takes an executor
933/// *instance* and unconditionally replaces `schedule.executor` with it (clearing
934/// `executor_initialized` too), so the fresh `SingleThreadedExecutor` arrives
935/// with an empty `completed_systems`.
936///
937/// On 0.15 this had to set two different *kinds* and swap back, because
938/// `set_executor_kind` was a no-op when the kind was unchanged - and
939/// `SimpleExecutor`, the other kind it used, no longer exists.
940fn reset_executor(schedule: &mut Schedule) {
941    schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    /// Serializes every test in this binary that swaps the **process-global**
949    /// panic hook - see the definition for why they can't run concurrently.
950    use crate::test_support::{PANIC_HOOK_LOCK, hints};
951
952    /// Run `f` with the process panic hook silenced (the panic is expected), and
953    /// serialized against the other hook-swapping tests.
954    fn with_silent_panics<T>(f: impl FnOnce() -> T) -> T {
955        let _hook_guard = PANIC_HOOK_LOCK
956            .lock()
957            .unwrap_or_else(std::sync::PoisonError::into_inner);
958        let prev_hook = std::panic::take_hook();
959        std::panic::set_hook(Box::new(|_| {}));
960        let out = f();
961        std::panic::set_hook(prev_hook);
962        out
963    }
964
965    #[test]
966    fn run_isolated_catches_a_system_panic_and_reports_the_agent() {
967        fn ok_system() {}
968        fn boom_system() {
969            panic!("simulated system panic");
970        }
971        // A system that panics *while working on a specific agent* - the shape
972        // every real pipeline system has.
973        fn boom_on_agent_system() {
974            crate::tick_scope::enter(
975                Entity::from_raw_u32(41)
976                    .expect("a small literal index is always a valid entity id"),
977            );
978            panic!("agent-scoped panic");
979        }
980        let mut world = World::new();
981
982        // A clean schedule ticks normally.
983        let mut ok = tick_schedule();
984        ok.add_systems(ok_system);
985        assert!(run_isolated(&mut ok, &mut world).is_ok());
986
987        // A panicking system is caught (the daemon would survive) and, with no
988        // agent in scope, reports no entity to blame.
989        let mut bad = tick_schedule();
990        bad.add_systems(boom_system);
991        let err = with_silent_panics(|| run_isolated(&mut bad, &mut world))
992            .expect_err("the panic must be caught");
993        assert_eq!(err.entity, None);
994        assert_eq!(err.message, "simulated system panic");
995
996        // With an agent in scope, the panic is attributed to it.
997        let mut blamed = tick_schedule();
998        blamed.add_systems(boom_on_agent_system);
999        let err = with_silent_panics(|| run_isolated(&mut blamed, &mut world))
1000            .expect_err("the panic must be caught");
1001        assert_eq!(
1002            err.entity,
1003            Some(
1004                Entity::from_raw_u32(41)
1005                    .expect("a small literal index is always a valid entity id")
1006            )
1007        );
1008        assert_eq!(err.message, "agent-scoped panic");
1009
1010        // A later clean tick must not inherit the previous tick's entity.
1011        assert!(run_isolated(&mut ok, &mut world).is_ok());
1012        assert_eq!(crate::tick_scope::current(), None);
1013    }
1014
1015    use crate::components::{AgentState, ContextWindow, InferenceConfig};
1016    use crate::pipeline::{
1017        AgentBlueprint, MessageIntake, StageCursor, StageInference, StageInferences, StageProgress,
1018        StageSetup, StageSetups, VisitCounts,
1019    };
1020    use crate::tool_bridge::BoxedToolExec;
1021    use leviath_core::{Region, RegionKind};
1022    use leviath_providers::{
1023        FinishReason, InferenceRequest, InferenceResponse, ModelCapabilities, Provider, TokenUsage,
1024        ToolCall,
1025    };
1026    use std::sync::Mutex;
1027
1028    /// A provider scripted with a queue of responses; each `infer` pops the next.
1029    struct Script {
1030        responses: Mutex<std::collections::VecDeque<InferenceResponse>>,
1031    }
1032
1033    #[async_trait::async_trait]
1034    impl Provider for Script {
1035        async fn infer(
1036            &self,
1037            _req: InferenceRequest,
1038        ) -> leviath_providers::Result<InferenceResponse> {
1039            let next = self.responses.lock().unwrap().pop_front();
1040            next.ok_or_else(|| ProviderError::Other("script exhausted".to_string()))
1041        }
1042        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1043            1
1044        }
1045        fn max_context_tokens(&self, _m: &str) -> usize {
1046            100_000
1047        }
1048        fn name(&self) -> &str {
1049            "script"
1050        }
1051        fn capabilities(&self, _m: &str) -> ModelCapabilities {
1052            ModelCapabilities::default()
1053        }
1054    }
1055
1056    fn text(content: &str) -> InferenceResponse {
1057        InferenceResponse {
1058            content: content.to_string(),
1059            tool_calls: vec![],
1060            tokens_used: TokenUsage {
1061                prompt_tokens: 1,
1062                completion_tokens: 1,
1063                total_tokens: 2,
1064                cached_tokens: 0,
1065                cache_write_tokens: 0,
1066            },
1067            finish_reason: FinishReason::Complete,
1068        }
1069    }
1070
1071    fn with_tool(id: &str, name: &str) -> InferenceResponse {
1072        let mut r = text("");
1073        r.tool_calls.push(ToolCall {
1074            id: id.to_string(),
1075            name: name.to_string(),
1076            arguments: serde_json::json!({}),
1077            thought_signature: None,
1078        });
1079        r
1080    }
1081
1082    /// A tool service that returns a fixed result string for every call.
1083    struct EchoTools;
1084    impl ToolService for EchoTools {
1085        fn exec_for(
1086            &self,
1087            _entity: Entity,
1088            calls: Vec<ToolCall>,
1089            _progress: crate::pipeline::ToolProgress,
1090        ) -> BoxedToolExec {
1091            Box::new(move || {
1092                Box::pin(async move {
1093                    calls
1094                        .into_iter()
1095                        .map(|c| (c.id, "ok".to_string()))
1096                        .collect()
1097                })
1098            })
1099        }
1100    }
1101
1102    fn window() -> ContextWindow {
1103        let mut w = ContextWindow::new(10_000);
1104        w.add_region(Region::new("sys".to_string(), RegionKind::Pinned, 2000));
1105        w.add_region(Region::new(
1106            "conversation".to_string(),
1107            RegionKind::Clearable,
1108            10_000,
1109        ));
1110        w.add_region(Region::new(
1111            "tool_results".to_string(),
1112            RegionKind::Temporary,
1113            5000,
1114        ));
1115        w
1116    }
1117
1118    fn agent_state() -> AgentState {
1119        AgentState {
1120            agent_id: "a".to_string(),
1121            current_stage: "s".to_string(),
1122            iteration: 0,
1123            status: AgentStatus::Active,
1124            spawned_children_ids: vec![],
1125            pending_wait: None,
1126            accepts_messages: true,
1127        }
1128    }
1129
1130    /// A stage advertising the tools the scripted responses here actually call.
1131    ///
1132    /// Advertising them is load-bearing: dispatch refuses tools a stage never
1133    /// offered, so with an empty tool list every end-to-end test that drives a
1134    /// tool call would short-circuit into a refusal and the tool service would
1135    /// never be reached at all.
1136    fn stage(model: &str) -> StageInference {
1137        StageInference {
1138            provider_name: "script".to_string(),
1139            model: model.to_string(),
1140            tools: ["do", "read"]
1141                .iter()
1142                .map(|n| leviath_providers::Tool {
1143                    name: (*n).to_string(),
1144                    description: String::new(),
1145                    parameters: serde_json::json!({}),
1146                })
1147                .collect(),
1148            tool_filter: None,
1149            fallbacks: Vec::new(),
1150        }
1151    }
1152
1153    fn setup() -> StageSetup {
1154        StageSetup {
1155            inference_config: InferenceConfig {
1156                temperature: None,
1157                max_output_tokens: None,
1158                extra_params: Default::default(),
1159                batch_tool_hint: false,
1160                shell_hint: false,
1161                request_timeout_secs: None,
1162            },
1163            routing: None,
1164            accepts_messages: true,
1165            context_layout: None,
1166            system_prompt: None,
1167        }
1168    }
1169
1170    fn blueprint() -> leviath_core::Blueprint {
1171        let layout = leviath_core::layout::ContextLayout::new(
1172            vec![leviath_core::layout::RegionDefinition::new(
1173                "conversation".to_string(),
1174                RegionKind::Clearable,
1175                10_000,
1176            )],
1177            12_000,
1178        );
1179        let s = leviath_core::Stage::new(
1180            "s".to_string(),
1181            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1182        );
1183        leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
1184    }
1185
1186    /// Spawn a single-stage agent, initially ready to infer.
1187    fn spawn(world: &mut PipelineWorld) -> Entity {
1188        world.spawn_agent((
1189            AgentBlueprint(blueprint()),
1190            StageCursor { index: 0 },
1191            agent_state(),
1192            crate::components::MessageInbox::default(),
1193            StageProgress::default(),
1194            StageInferences(vec![stage("m")]),
1195            StageSetups(vec![setup()]),
1196            VisitCounts::default(),
1197            window(),
1198            stage("m"),
1199            setup().inference_config,
1200            ReadyToInfer,
1201        ))
1202    }
1203
1204    fn build_world(providers: ProviderRegistry) -> PipelineWorld {
1205        // These agents carry no RunMetadata, so persistence never fires; run the
1206        // world fully in memory.
1207        PipelineWorld::new(
1208            providers,
1209            Arc::new(EchoTools),
1210            InferencePoolConfig::new(),
1211            1,
1212            None,
1213            Handle::current(),
1214        )
1215    }
1216
1217    #[tokio::test]
1218    async fn open_circuits_reports_nothing_without_the_breaker() {
1219        // An embedded world that never installed the resource must report a
1220        // clean bill of health rather than panicking on a missing resource.
1221        let world = build_world(ProviderRegistry::new());
1222        assert!(world.open_circuits().is_empty());
1223    }
1224
1225    #[tokio::test]
1226    async fn open_circuits_reports_a_tripped_provider() {
1227        let mut world = build_world(ProviderRegistry::new());
1228        let policy = crate::pipeline::CircuitPolicy {
1229            failures_before_open: 1,
1230            cooldown_secs: 300,
1231        };
1232        let mut circuits = crate::pipeline::ProviderCircuits::default();
1233        circuits.record_failure(
1234            "openrouter",
1235            leviath_providers::UnavailableReason::CreditsExhausted,
1236            chrono::Utc::now().timestamp(),
1237            &policy,
1238        );
1239        world.world_mut().insert_resource(circuits);
1240        world.world_mut().insert_resource(policy);
1241
1242        let open = world.open_circuits();
1243        assert_eq!(open.len(), 1);
1244        assert_eq!(open[0].provider, "openrouter");
1245        assert_eq!(
1246            open[0].reason,
1247            leviath_providers::UnavailableReason::CreditsExhausted
1248        );
1249    }
1250
1251    #[tokio::test]
1252    async fn open_circuits_falls_back_to_the_default_policy() {
1253        // Circuits installed, policy not: the default must apply rather than
1254        // the report silently coming back empty.
1255        let mut world = build_world(ProviderRegistry::new());
1256        let default_policy = crate::pipeline::CircuitPolicy::default();
1257        let mut circuits = crate::pipeline::ProviderCircuits::default();
1258        for _ in 0..default_policy.failures_before_open {
1259            circuits.record_failure(
1260                "openrouter",
1261                leviath_providers::UnavailableReason::AuthFailed,
1262                chrono::Utc::now().timestamp(),
1263                &default_policy,
1264            );
1265        }
1266        world.world_mut().insert_resource(circuits);
1267
1268        assert_eq!(world.open_circuits().len(), 1);
1269    }
1270
1271    #[tokio::test]
1272    async fn set_exact_token_counting_toggles_the_stage_flag() {
1273        let mut world = build_world(ProviderRegistry::new());
1274        // Default is off.
1275        assert!(
1276            !world
1277                .world()
1278                .resource::<crate::pipeline::InferenceStage>()
1279                .exact_token_counting
1280        );
1281        world.set_exact_token_counting(true);
1282        assert!(
1283            world
1284                .world()
1285                .resource::<crate::pipeline::InferenceStage>()
1286                .exact_token_counting
1287        );
1288    }
1289
1290    #[tokio::test]
1291    async fn run_to_fixed_point_survives_a_panicking_system() {
1292        // A system that panics must not hang or crash the drive loop - it's
1293        // caught and the loop breaks (the daemon survives).
1294        fn boom_system() {
1295            panic!("simulated system panic");
1296        }
1297        let mut world = build_world(ProviderRegistry::new());
1298        world.add_test_system(boom_system);
1299        // Unattributed: nothing to fail, so the round stops immediately.
1300        with_silent_panics(|| world.run_to_fixed_point());
1301    }
1302
1303    #[tokio::test]
1304    async fn a_panic_on_the_compute_pool_is_attributed_to_its_agent() {
1305        // `dispatch_inference` fans its per-agent work out over the compute task
1306        // pool, where the thread-local scope can't reach the driver thread that
1307        // catches unwinds. Those bodies run under `run_agent_parallel`, which
1308        // catches on the pool thread and marks the agent instead - this proves
1309        // the marker makes it back and fails the right run (issue #109).
1310        fn boom_in_parallel(
1311            agents: Query<(Entity, &AgentState)>,
1312            par_commands: bevy_ecs::system::ParallelCommands,
1313        ) {
1314            agents.par_iter().for_each(|(entity, state)| {
1315                if state.status != AgentStatus::Active {
1316                    return; // already failed - nothing left to blow up
1317                }
1318                // Clear the thread-local first: whatever attributes this panic,
1319                // it is demonstrably not the `enter`/`current` mechanism.
1320                crate::tick_scope::clear();
1321                crate::tick_scope::run_agent_parallel(entity, &par_commands, &mut || {
1322                    panic!("blew up on the compute pool");
1323                });
1324            });
1325        }
1326
1327        let mut world = build_world(ProviderRegistry::new());
1328        let entity = spawn(&mut world);
1329        world.add_test_system(boom_in_parallel);
1330        with_silent_panics(|| world.run_to_fixed_point());
1331
1332        let status = world.agent_status(entity);
1333        assert!(
1334            matches!(status, Some(AgentStatus::Error { ref message })
1335                if message.contains("a pipeline system panicked")
1336                    && message.contains("blew up on the compute pool")),
1337            "got: {status:?}"
1338        );
1339        // The marker is consumed, so a later tick doesn't re-fail the agent.
1340        assert!(
1341            world
1342                .world()
1343                .entity(entity)
1344                .get::<crate::tick_scope::PanickedInParallel>()
1345                .is_none(),
1346            "the marker must be drained once acted on"
1347        );
1348    }
1349
1350    #[tokio::test]
1351    async fn a_panicking_system_fails_its_agent_instead_of_looping_forever() {
1352        // Before issue #109 was fixed, a panicking system was swallowed
1353        // anonymously: nothing changed, so the very next wake re-ticked the same
1354        // state and panicked again, forever, while every other agent stalled.
1355        // Now the agent in scope is failed, which takes it out of the dispatch
1356        // systems (they only act on `Active` agents) and lets the world settle.
1357        static VICTIM: std::sync::Mutex<Option<Entity>> = std::sync::Mutex::new(None);
1358        static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1359
1360        fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1361            // No trailing statements after the `panic!`: an unreachable tail
1362            // would read as uncovered under the workspace's 100% gate.
1363            let Some((entity, _)) = agents
1364                .iter()
1365                .find(|(_, state)| state.status == AgentStatus::Active)
1366            else {
1367                return; // the agent has been failed - nothing left to blow up
1368            };
1369            crate::tick_scope::enter(entity);
1370            *VICTIM
1371                .lock()
1372                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(entity);
1373            PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1374            panic!("blew up on this agent");
1375        }
1376
1377        let mut world = build_world(ProviderRegistry::new());
1378        let entity = spawn(&mut world);
1379        world.add_test_system(boom_on_active_agent);
1380        with_silent_panics(|| world.run_to_fixed_point());
1381
1382        let victim = VICTIM
1383            .lock()
1384            .unwrap_or_else(std::sync::PoisonError::into_inner)
1385            .take();
1386        assert_eq!(victim, Some(entity), "the system saw the spawned agent");
1387        let status = world.agent_status(entity);
1388        assert!(
1389            matches!(status, Some(AgentStatus::Error { ref message })
1390                if message.contains("a pipeline system panicked")
1391                    && message.contains("blew up on this agent")),
1392            "got: {status:?}"
1393        );
1394        // The loop terminated rather than re-panicking without bound.
1395        assert!(
1396            PANICS.load(std::sync::atomic::Ordering::SeqCst) <= MAX_TICK_FAILURES_PER_ROUND + 1,
1397            "the panic budget must stop the round"
1398        );
1399    }
1400
1401    fn registry_with(responses: Vec<InferenceResponse>) -> ProviderRegistry {
1402        let mut r = ProviderRegistry::new();
1403        r.register(
1404            "script".to_string(),
1405            Arc::new(Script {
1406                responses: Mutex::new(responses.into_iter().collect()),
1407            }),
1408        );
1409        r
1410    }
1411
1412    #[tokio::test]
1413    async fn an_agent_whose_provider_is_missing_wedges_at_iteration_zero() {
1414        // Issue #190. The registry has no `script` provider, so
1415        // `dispatch_inference` declines and leaves the agent `ReadyToInfer`.
1416        // Nothing about the world changed, so the fixed point is reached
1417        // immediately and nothing is in flight to wake the driver - the agent
1418        // used to sit `Active` at iteration 0 for ever, which on disk reads as
1419        // a `running` run with no tokens and a frozen `updated_at`.
1420        let mut world = build_world(ProviderRegistry::new());
1421        let e = spawn(&mut world);
1422
1423        world.run_until_idle(30).await;
1424
1425        // Nothing has dispatched, and within the grace period that is still
1426        // just a wait - but it is now a *recorded* one.
1427        let state = world.world().get::<AgentState>(e).expect("the agent");
1428        assert_eq!(state.iteration, 0, "not a single inference happened");
1429        assert_eq!(state.status, AgentStatus::Active);
1430        let stall = world
1431            .world()
1432            .get::<crate::pipeline::DispatchStall>(e)
1433            .expect("the decline is recorded");
1434        assert_eq!(stall.reason, crate::pipeline::StallReason::ProviderMissing);
1435
1436        // Backdate it past the grace period, as the host's redrive timer would
1437        // find it on a later tick, and the run fails with an answer rather than
1438        // hanging.
1439        let past =
1440            chrono::Utc::now().timestamp() - crate::pipeline::DEFAULT_STALL_TIMEOUT_SECS as i64 - 1;
1441        world
1442            .world_mut()
1443            .get_mut::<crate::pipeline::DispatchStall>(e)
1444            .expect("the stall record")
1445            .since = past;
1446        world.run_to_fixed_point();
1447
1448        let status = world.agent_status(e);
1449        assert!(
1450            matches!(status, Some(AgentStatus::Error { ref message })
1451                if message.contains("script") && message.contains("not configured")),
1452            "got: {status:?}"
1453        );
1454        assert!(
1455            world.world().get::<ReadyToInfer>(e).is_none(),
1456            "and it is out of the dispatch systems"
1457        );
1458    }
1459
1460    /// Issue #202, end to end through the real schedule: an agent stripped of
1461    /// every phase marker is unreachable, and the watchdog registered in the
1462    /// chain above fails it rather than leaving it `running` for ever.
1463    ///
1464    /// This also proves the fixed-point loop still converges with the new system
1465    /// in it. The watchdog writes a `Wedged` record on its first pass, so a tick
1466    /// does change the world; if that record fed the fingerprint the loop would
1467    /// spin instead of parking, which is why it deliberately does not.
1468    #[tokio::test]
1469    async fn a_run_nothing_can_drive_is_failed_rather_than_left_running() {
1470        let mut world = build_world(registry_with(vec![]));
1471        world
1472            .world_mut()
1473            .insert_resource(crate::pipeline::WedgeTimeout(60));
1474        let e = spawn(&mut world);
1475
1476        // Strip the agent of the marker it spawned with. Nothing in the engine
1477        // does this; a panicking system that dropped a marker without landing a
1478        // successor is what it stands in for.
1479        world.world_mut().entity_mut(e).remove::<ReadyToInfer>();
1480        world.run_to_fixed_point();
1481
1482        // First pass records it. Inside the grace period it is still just a wait.
1483        assert_eq!(
1484            world.agent_status(e),
1485            Some(AgentStatus::Active),
1486            "not failed while it is still inside the grace period"
1487        );
1488        let since = world
1489            .world()
1490            .get::<crate::pipeline::Wedged>(e)
1491            .expect("the wedge is recorded")
1492            .since;
1493
1494        // Backdate past the grace period, as the host's redrive would find it.
1495        world
1496            .world_mut()
1497            .get_mut::<crate::pipeline::Wedged>(e)
1498            .expect("the wedge record")
1499            .since = since - 61;
1500        world.run_to_fixed_point();
1501
1502        let status = world.agent_status(e);
1503        assert!(
1504            matches!(status, Some(AgentStatus::Error { ref message })
1505                if message.contains("never move again")),
1506            "got: {status:?}"
1507        );
1508    }
1509
1510    #[tokio::test]
1511    async fn agent_completes_after_nudges_exhausted() {
1512        // Text-only responses with no tool calls get nudged up to the max; the
1513        // response after the last nudge is accepted and the single-stage
1514        // blueprint terminates the agent. (Exercises the handle_empty_response
1515        // nudge loop end-to-end through the driver.)
1516        let mut world = build_world(registry_with(vec![
1517            text("thinking"),
1518            text("still"),
1519            text("more"),
1520            text("final"),
1521        ]));
1522        let e = spawn(&mut world);
1523
1524        world.run_until_idle(30).await;
1525
1526        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1527    }
1528
1529    #[tokio::test]
1530    async fn agent_nudge_max_bounds_the_loop_end_to_end() {
1531        // `[agent.nudge] max = 1` (issue #127): the second text-only response
1532        // is final, so a two-response script finishes where the default cap
1533        // would have demanded four. A third scripted response left unconsumed
1534        // would keep the driver looping past run_until_idle's budget.
1535        let mut world = build_world(registry_with(vec![text("thinking"), text("final")]));
1536        let mut bp = blueprint();
1537        bp.nudge = Some(leviath_core::NudgeConfig {
1538            max: Some(1),
1539            ..Default::default()
1540        });
1541        let e = world.spawn_agent((
1542            AgentBlueprint(bp),
1543            StageCursor { index: 0 },
1544            agent_state(),
1545            crate::components::MessageInbox::default(),
1546            StageProgress::default(),
1547            StageInferences(vec![stage("m")]),
1548            StageSetups(vec![setup()]),
1549            VisitCounts::default(),
1550            window(),
1551            stage("m"),
1552            setup().inference_config,
1553            ReadyToInfer,
1554        ));
1555
1556        world.run_until_idle(30).await;
1557
1558        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1559    }
1560
1561    #[tokio::test]
1562    async fn agent_runs_tools_then_completes() {
1563        // First response calls a tool; after the tool result comes back the
1564        // second response is text-only, finishing the run.
1565        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1566        let e = spawn(&mut world);
1567
1568        world.run_until_idle(20).await;
1569
1570        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1571        // With no routing configured, tool results land in the conversation
1572        // region.
1573        assert!(
1574            world
1575                .world()
1576                .get::<ContextWindow>(e)
1577                .unwrap()
1578                .get_region("conversation")
1579                .unwrap()
1580                .current_tokens
1581                > 0
1582        );
1583    }
1584
1585    #[tokio::test]
1586    async fn insert_interaction_hub_installs_resource_and_attaches_wake() {
1587        use crate::dynamic_interaction::InteractionBackend;
1588        use crate::interaction_hub::InteractionHub;
1589        let mut world = build_world(registry_with(vec![]));
1590        let hub = InteractionHub::new();
1591        world.insert_interaction_hub(hub.clone());
1592
1593        // The hub is now a world resource the reflect system reads.
1594        assert!(world.world().get_resource::<InteractionHub>().is_some());
1595
1596        // The wake handle was attached: opening a request nudges the same wake
1597        // the driver parks on (a later notified() returns immediately).
1598        let backend = hub.backend_for("x");
1599        let asking = tokio::spawn(async move {
1600            backend
1601                .ask(leviath_core::interaction::InteractionRequest::free_text(
1602                    "q", "p", "s", true,
1603                ))
1604                .await
1605        });
1606        for _ in 0..8 {
1607            tokio::task::yield_now().await;
1608        }
1609        world.wake_handle().notified().await;
1610        hub.cancel("q");
1611        let _ = asking.await;
1612    }
1613
1614    #[tokio::test]
1615    async fn provider_error_marks_agent_error() {
1616        // Empty script ⇒ the very first infer errors.
1617        let mut world = build_world(registry_with(vec![]));
1618        let e = spawn(&mut world);
1619
1620        world.run_until_idle(20).await;
1621
1622        assert_eq!(
1623            std::mem::discriminant(&world.agent_status(e).unwrap()),
1624            std::mem::discriminant(&AgentStatus::Error {
1625                message: String::new()
1626            })
1627        );
1628    }
1629
1630    #[tokio::test]
1631    async fn send_message_reaches_the_agent_inbox() {
1632        // No responses queued: the agent dispatches inference and parks awaiting
1633        // it. We deliver a message; the deliver system routes it to context.
1634        let mut world = build_world(registry_with(vec![]));
1635        let e = spawn(&mut world);
1636        // Drive to the point the first (doomed) inference is dispatched/collected.
1637        world.run_until_idle(20).await;
1638
1639        world
1640            .send_message(AgentMessage {
1641                agent_id: "a".to_string(),
1642                content: "hello".to_string(),
1643                target_region: Some("conversation".to_string()),
1644            })
1645            .unwrap();
1646        world.tick(); // deliver_messages runs
1647
1648        assert!(
1649            world
1650                .world()
1651                .get::<ContextWindow>(e)
1652                .unwrap()
1653                .get_region("conversation")
1654                .unwrap()
1655                .current_tokens
1656                > 0
1657        );
1658    }
1659
1660    #[tokio::test]
1661    async fn run_returns_on_shutdown() {
1662        let mut world = build_world(registry_with(vec![text("done")]));
1663        spawn(&mut world);
1664        world.shutdown(); // pre-signal: run parks then returns
1665        // Must return rather than loop forever.
1666        world.run().await;
1667    }
1668
1669    #[tokio::test]
1670    async fn run_wakes_then_shuts_down() {
1671        // Drives run() on its own task: a wake makes it loop once (wake branch),
1672        // then a shutdown makes it return (shutdown branch).
1673        let mut world = build_world(registry_with(vec![
1674            text("t1"),
1675            text("t2"),
1676            text("t3"),
1677            text("t4"),
1678        ]));
1679        spawn(&mut world);
1680        let wake = world.wake_handle();
1681        let shutdown = world.shutdown_handle();
1682        let handle = tokio::spawn(async move { world.run().await });
1683
1684        wake.notify_one();
1685        tokio::task::yield_now().await;
1686        shutdown.notify_one();
1687
1688        handle.await.unwrap(); // returns cleanly
1689    }
1690
1691    #[tokio::test]
1692    async fn send_message_errors_when_intake_dropped() {
1693        let mut world = build_world(registry_with(vec![]));
1694        // Drop the intake receiver via the world accessor, closing the channel.
1695        let removed = world.world_mut().remove_resource::<MessageIntake>();
1696        drop(removed);
1697
1698        let err = world.send_message(AgentMessage {
1699            agent_id: "a".to_string(),
1700            content: "x".to_string(),
1701            target_region: None,
1702        });
1703        assert!(err.is_err());
1704    }
1705
1706    #[tokio::test]
1707    async fn script_provider_metadata_is_exercised() {
1708        // Keep the mock's non-`infer`/`capabilities` methods measured.
1709        let p = Script {
1710            responses: Mutex::new(std::collections::VecDeque::new()),
1711        };
1712        assert_eq!(p.name(), "script");
1713        assert_eq!(p.count_tokens("t", "m").await, 1);
1714        assert_eq!(p.max_context_tokens("m"), 100_000);
1715        let _ = p.capabilities("m");
1716    }
1717
1718    #[tokio::test]
1719    async fn agent_status_is_none_for_unknown_entity() {
1720        let world = build_world(registry_with(vec![]));
1721        assert_eq!(
1722            world.agent_status(
1723                Entity::from_raw_u32(999)
1724                    .expect("a small literal index is always a valid entity id")
1725            ),
1726            None
1727        );
1728    }
1729
1730    #[tokio::test]
1731    async fn paused_agent_does_not_progress_until_resumed() {
1732        let mut world = build_world(registry_with(vec![
1733            text("t1"),
1734            text("t2"),
1735            text("t3"),
1736            text("t4"),
1737        ]));
1738        let e = spawn(&mut world);
1739        assert!(world.pause(e));
1740
1741        world.run_until_idle(30).await;
1742        // Paused ⇒ parked, never inferred.
1743        assert_eq!(world.agent_status(e), Some(AgentStatus::Paused));
1744
1745        assert!(world.resume(e));
1746        world.run_until_idle(30).await;
1747        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1748    }
1749
1750    #[tokio::test]
1751    async fn pause_refuses_waiting_and_terminal_agents() {
1752        let mut world = build_world(registry_with(vec![text("t1")]));
1753        let e = spawn(&mut world);
1754
1755        // A Waiting agent's status is the marker fan-out merges and interaction
1756        // resolution key off - pause must not clobber it.
1757        world.set_status(e, AgentStatus::Waiting);
1758        assert!(!world.pause(e));
1759        assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
1760
1761        world.set_status(e, AgentStatus::Cancelled);
1762        assert!(!world.pause(e));
1763        assert_eq!(world.agent_status(e), Some(AgentStatus::Cancelled));
1764    }
1765
1766    #[tokio::test]
1767    async fn resume_refuses_agents_that_are_not_paused_or_idle() {
1768        let mut world = build_world(registry_with(vec![text("t1")]));
1769        let e = spawn(&mut world);
1770
1771        // Already running: nothing to resume.
1772        world.set_status(e, AgentStatus::Active);
1773        assert!(!world.resume(e));
1774
1775        world.set_status(e, AgentStatus::Waiting);
1776        assert!(!world.resume(e));
1777        assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
1778
1779        world.set_status(e, AgentStatus::Complete);
1780        assert!(!world.resume(e));
1781        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1782    }
1783
1784    #[tokio::test]
1785    async fn resume_nudges_an_idle_agent_active() {
1786        let mut world = build_world(registry_with(vec![text("t1")]));
1787        let e = spawn(&mut world);
1788        world.set_status(e, AgentStatus::Idle);
1789        assert!(world.resume(e));
1790        assert_eq!(world.agent_status(e), Some(AgentStatus::Active));
1791    }
1792
1793    #[tokio::test]
1794    async fn cancelled_agent_stops_progressing() {
1795        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1796        let e = spawn(&mut world);
1797        assert!(world.cancel(e));
1798
1799        world.run_until_idle(20).await;
1800
1801        assert_eq!(world.agent_status(e), Some(AgentStatus::Cancelled));
1802    }
1803
1804    #[tokio::test]
1805    async fn status_ops_return_false_for_unknown_entity() {
1806        let mut world = build_world(registry_with(vec![]));
1807        assert!(!world.pause(
1808            Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1809        ));
1810        assert!(!world.resume(
1811            Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1812        ));
1813        assert!(!world.cancel(
1814            Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1815        ));
1816    }
1817
1818    #[tokio::test]
1819    async fn spawn_from_blueprint_builds_a_runnable_agent() {
1820        // End-to-end via the blueprint resolver: build → drive → complete.
1821        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1822        let e = world
1823            .spawn_from_blueprint(
1824                "agent-1".to_string(),
1825                blueprint(),
1826                "do the task",
1827                vec![crate::pipeline::ResolvedStage {
1828                    provider_name: "script".to_string(),
1829                    model: "m".to_string(),
1830                    tools: vec![],
1831                    fallbacks: Vec::new(),
1832                }],
1833                hints(true),
1834            )
1835            .unwrap();
1836
1837        world.run_until_idle(20).await;
1838
1839        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1840    }
1841
1842    #[tokio::test]
1843    async fn persists_agent_snapshot_to_runs_dir() {
1844        // An agent carrying RunMetadata + TokenTotals is snapshotted to disk as it
1845        // runs; after it completes, meta.json exists with the final status.
1846        let dir = tempfile::tempdir().unwrap();
1847        let mut world = PipelineWorld::new(
1848            registry_with(vec![with_tool("c1", "do"), text("done")]),
1849            Arc::new(EchoTools),
1850            InferencePoolConfig::new(),
1851            1,
1852            Some(dir.path().to_path_buf()),
1853            Handle::current(),
1854        );
1855        world.spawn_agent((
1856            AgentBlueprint(blueprint()),
1857            StageCursor { index: 0 },
1858            agent_state(),
1859            crate::components::MessageInbox::default(),
1860            StageProgress::default(),
1861            StageInferences(vec![stage("m")]),
1862            StageSetups(vec![setup()]),
1863            VisitCounts::default(),
1864            window(),
1865            stage("m"),
1866            setup().inference_config,
1867            crate::persistence::RunMetadata {
1868                run_id: "run-42".to_string(),
1869                agent_name: "a".to_string(),
1870                agent_path: "/p".to_string(),
1871                task: "t".to_string(),
1872                model: None,
1873                // A real directory: the tick chain fails a run whose workspace is gone.
1874                workdir: std::env::temp_dir().to_string_lossy().to_string(),
1875                num_stages: 1,
1876                started_at: 0,
1877                parent_run_id: None,
1878                metadata: std::collections::HashMap::new(),
1879                callback_url: None,
1880                callback_secret: None,
1881                title: None,
1882                unattended: false,
1883                read_paths: None,
1884            },
1885            crate::persistence::TokenTotals::default(),
1886            crate::pipeline::PersistWatermark::default(),
1887            ReadyToInfer,
1888        ));
1889
1890        world.run_until_idle(20).await;
1891
1892        // The persistence worker is fire-and-forget on its own task; poll until the
1893        // final (Complete) snapshot has been flushed. A short real sleep between
1894        // polls (rather than a bare `yield_now`) gives the worker's write actual
1895        // wall-clock time to land under load - otherwise the loop can spin through
1896        // every iteration before the write completes and spuriously time out.
1897        let meta_path = dir.path().join("run-42").join("meta.json");
1898        let mut meta = None;
1899        for _ in 0..200 {
1900            if let Ok(text) = std::fs::read_to_string(&meta_path)
1901                && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1902                && m.status == leviath_core::run_meta::RunStatus::Complete
1903            {
1904                meta = Some(m);
1905                break;
1906            }
1907            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1908        }
1909
1910        let meta = meta.expect("final Complete snapshot flushed to disk");
1911        assert_eq!(meta.run_id, "run-42");
1912        assert!(dir.path().join("run-42").join("context.json").exists());
1913    }
1914
1915    #[tokio::test]
1916    async fn a_panicked_agent_is_recorded_as_errored_on_disk() {
1917        // The reported symptom in issue #109: a crashed run stayed `"running"`
1918        // in meta.json forever. `dispatch_persistence` is the *last* system in
1919        // the chain, so the tick that panics never reaches it - which is exactly
1920        // why `run_to_fixed_point` keeps driving after failing the agent.
1921        fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1922            let Some((entity, _)) = agents
1923                .iter()
1924                .find(|(_, state)| state.status == AgentStatus::Active)
1925            else {
1926                return; // the agent has been failed - nothing left to blow up
1927            };
1928            crate::tick_scope::enter(entity);
1929            panic!("exploded mid-stage");
1930        }
1931
1932        let dir = tempfile::tempdir().unwrap();
1933        let mut world = PipelineWorld::new(
1934            registry_with(vec![]),
1935            Arc::new(EchoTools),
1936            InferencePoolConfig::new(),
1937            1,
1938            Some(dir.path().to_path_buf()),
1939            Handle::current(),
1940        );
1941        world.spawn_agent((
1942            AgentBlueprint(blueprint()),
1943            StageCursor { index: 0 },
1944            agent_state(),
1945            crate::components::MessageInbox::default(),
1946            StageProgress::default(),
1947            StageInferences(vec![stage("m")]),
1948            StageSetups(vec![setup()]),
1949            VisitCounts::default(),
1950            window(),
1951            stage("m"),
1952            setup().inference_config,
1953            crate::persistence::RunMetadata {
1954                run_id: "run-boom".to_string(),
1955                agent_name: "a".to_string(),
1956                agent_path: "/p".to_string(),
1957                task: "t".to_string(),
1958                model: None,
1959                workdir: "/w".to_string(),
1960                num_stages: 1,
1961                started_at: 0,
1962                parent_run_id: None,
1963                metadata: std::collections::HashMap::new(),
1964                callback_url: None,
1965                callback_secret: None,
1966                title: None,
1967                unattended: false,
1968                read_paths: None,
1969            },
1970            crate::persistence::TokenTotals::default(),
1971            crate::pipeline::PersistWatermark::default(),
1972            ReadyToInfer,
1973        ));
1974        world.add_test_system(boom_on_active_agent);
1975        with_silent_panics(|| world.run_to_fixed_point());
1976
1977        let meta_path = dir.path().join("run-boom").join("meta.json");
1978        let mut meta = None;
1979        for _ in 0..200 {
1980            if let Ok(text) = std::fs::read_to_string(&meta_path)
1981                && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1982                && m.status == leviath_core::run_meta::RunStatus::Error
1983            {
1984                meta = Some(m);
1985                break;
1986            }
1987            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1988        }
1989        let meta = meta.expect("the panicked run must be persisted as errored");
1990        let error = meta.error.unwrap_or_default();
1991        assert!(error.contains("a pipeline system panicked"), "got: {error}");
1992        assert!(error.contains("exploded mid-stage"), "got: {error}");
1993    }
1994
1995    /// A single-stage blueprint whose stage is an `interactive_points` stage with a
1996    /// `plan_approval` point (the shape that blocks awaiting human approval).
1997    fn interactive_blueprint() -> leviath_core::Blueprint {
1998        use leviath_core::blueprint::{InteractionPoint, InteractionStyle, StageMode};
1999        let layout = leviath_core::layout::ContextLayout::new(
2000            vec![leviath_core::layout::RegionDefinition::new(
2001                "conversation".to_string(),
2002                RegionKind::Clearable,
2003                10_000,
2004            )],
2005            12_000,
2006        );
2007        let mut s = leviath_core::Stage::new(
2008            "plan".to_string(),
2009            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
2010        );
2011        s.mode = StageMode::InteractivePoints {
2012            points: vec![InteractionPoint {
2013                name: "plan_approval".to_string(),
2014                prompt: "Approve?".to_string(),
2015                required: true,
2016                unattended: leviath_core::blueprint::UnattendedPolicy::AutoApprove,
2017                style: InteractionStyle::MultipleChoice,
2018                options: vec!["Approve".to_string(), "Abort".to_string()],
2019                directives: std::collections::HashMap::new(),
2020                abort_options: vec!["Abort".to_string()],
2021                edit_options: vec![],
2022                document_region: None,
2023            }],
2024        };
2025        leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
2026    }
2027
2028    #[tokio::test]
2029    async fn persists_interaction_point_when_a_live_agent_blocks() {
2030        // Drive a real agent through inference → transition → the interaction-point
2031        // lane until it blocks awaiting approval, and assert the daemon wrote the
2032        // `interactions.json` sidecar - the issue #38 persist side, end-to-end
2033        // through the live lane (a tool call first, then a text "plan", so the stage
2034        // transitions into the interaction point rather than looping on nudges).
2035        let dir = tempfile::tempdir().unwrap();
2036        let mut world = PipelineWorld::new(
2037            registry_with(vec![with_tool("c1", "read"), text("## Plan\n1. do it")]),
2038            Arc::new(EchoTools),
2039            InferencePoolConfig::new(),
2040            1,
2041            Some(dir.path().to_path_buf()),
2042            Handle::current(),
2043        );
2044        world.insert_interaction_hub(crate::interaction_hub::InteractionHub::new());
2045        let e = world.spawn_agent((
2046            AgentBlueprint(interactive_blueprint()),
2047            StageCursor { index: 0 },
2048            agent_state(),
2049            crate::components::MessageInbox::default(),
2050            StageProgress::default(),
2051            StageInferences(vec![stage("m")]),
2052            StageSetups(vec![setup()]),
2053            VisitCounts::default(),
2054            window(),
2055            stage("m"),
2056            setup().inference_config,
2057            crate::persistence::RunMetadata {
2058                run_id: "run-ip".to_string(),
2059                agent_name: "a".to_string(),
2060                agent_path: "/p".to_string(),
2061                task: "t".to_string(),
2062                model: None,
2063                // A real directory: the tick chain fails a run whose workspace is gone.
2064                workdir: std::env::temp_dir().to_string_lossy().to_string(),
2065                num_stages: 1,
2066                started_at: 0,
2067                parent_run_id: None,
2068                metadata: std::collections::HashMap::new(),
2069                callback_url: None,
2070                callback_secret: None,
2071                title: None,
2072                unattended: false,
2073                read_paths: None,
2074            },
2075            crate::persistence::TokenTotals::default(),
2076            crate::pipeline::PersistWatermark::default(),
2077            ReadyToInfer,
2078        ));
2079
2080        world.run_until_idle(30).await;
2081        // `run_until_idle` stops once no inference/tool is in flight, but the
2082        // interaction-point ask task registers in the hub just after; the real
2083        // daemon's `run()` loop catches its wake, so pump fixed points here until
2084        // `reflect_interaction_status` flips the agent to Waiting (and persistence
2085        // captures the sidecar).
2086        for _ in 0..50 {
2087            if world.agent_status(e) == Some(AgentStatus::Waiting) {
2088                break;
2089            }
2090            tokio::task::yield_now().await;
2091            world.run_to_fixed_point();
2092        }
2093        assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
2094
2095        // Poll until the interaction sidecar lands (the persistence worker writes it
2096        // on its own task once the agent is parked Waiting at the point).
2097        let path = dir.path().join("run-ip").join("interactions.json");
2098        let mut sidecar = None;
2099        for _ in 0..200 {
2100            if let Ok(t) = std::fs::read_to_string(&path)
2101                && let Ok(s) =
2102                    serde_json::from_str::<crate::interaction_points::InteractionPointState>(&t)
2103            {
2104                sidecar = Some(s);
2105                break;
2106            }
2107            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2108        }
2109        let s = sidecar.expect("interaction-point sidecar flushed to disk");
2110        assert_eq!(s.cursor, 0);
2111        assert_eq!(s.round, 0);
2112        assert_eq!(s.body, "## Plan\n1. do it");
2113    }
2114
2115    #[tokio::test]
2116    async fn flush_and_stop_drains_queued_snapshots() {
2117        // Unlike a plain shutdown, `flush_and_stop` awaits the persistence worker,
2118        // so the final snapshot is guaranteed on disk the instant it returns - no
2119        // filesystem polling required (contrast the test above).
2120        let dir = tempfile::tempdir().unwrap();
2121        let mut world = PipelineWorld::new(
2122            registry_with(vec![with_tool("c1", "do"), text("done")]),
2123            Arc::new(EchoTools),
2124            InferencePoolConfig::new(),
2125            1,
2126            Some(dir.path().to_path_buf()),
2127            Handle::current(),
2128        );
2129        world.spawn_agent((
2130            AgentBlueprint(blueprint()),
2131            StageCursor { index: 0 },
2132            agent_state(),
2133            crate::components::MessageInbox::default(),
2134            StageProgress::default(),
2135            StageInferences(vec![stage("m")]),
2136            StageSetups(vec![setup()]),
2137            VisitCounts::default(),
2138            window(),
2139            stage("m"),
2140            setup().inference_config,
2141            crate::persistence::RunMetadata {
2142                run_id: "run-flush".to_string(),
2143                agent_name: "a".to_string(),
2144                agent_path: "/p".to_string(),
2145                task: "t".to_string(),
2146                model: None,
2147                // A real directory: the tick chain fails a run whose workspace is gone.
2148                workdir: std::env::temp_dir().to_string_lossy().to_string(),
2149                num_stages: 1,
2150                started_at: 0,
2151                parent_run_id: None,
2152                metadata: std::collections::HashMap::new(),
2153                callback_url: None,
2154                callback_secret: None,
2155                title: None,
2156                unattended: false,
2157                read_paths: None,
2158            },
2159            crate::persistence::TokenTotals::default(),
2160            crate::pipeline::PersistWatermark::default(),
2161            ReadyToInfer,
2162        ));
2163
2164        world.run_until_idle(20).await;
2165        world.flush_and_stop().await;
2166
2167        // Read immediately - the drain guarantees the write landed.
2168        let meta_path = dir.path().join("run-flush").join("meta.json");
2169        let text = std::fs::read_to_string(&meta_path).expect("meta.json flushed on stop");
2170        let meta: leviath_core::run_meta::RunMeta = serde_json::from_str(&text).unwrap();
2171        assert_eq!(meta.run_id, "run-flush");
2172        assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Complete);
2173
2174        // A second call is a no-op (resource already removed, task taken) - no panic.
2175        world.flush_and_stop().await;
2176        assert!(meta_path.exists());
2177    }
2178
2179    #[tokio::test]
2180    async fn in_memory_world_runs_and_flushes_without_touching_disk() {
2181        // `runs_dir: None` is the embedding mode: the agent runs to completion,
2182        // snapshots are produced and drained exactly as in the persistent world
2183        // (same watermark/log behavior), but nothing lands on disk. The tempdir
2184        // doubles as the agent workdir and as the canary a persistent world
2185        // would have written run dirs and a machine-id into.
2186        let dir = tempfile::tempdir().unwrap();
2187        let mut world = PipelineWorld::new(
2188            registry_with(vec![with_tool("c1", "do"), text("done")]),
2189            Arc::new(EchoTools),
2190            InferencePoolConfig::new(),
2191            1,
2192            None,
2193            Handle::current(),
2194        );
2195        let entity = world.spawn_agent((
2196            AgentBlueprint(blueprint()),
2197            StageCursor { index: 0 },
2198            agent_state(),
2199            crate::components::MessageInbox::default(),
2200            StageProgress::default(),
2201            StageInferences(vec![stage("m")]),
2202            StageSetups(vec![setup()]),
2203            VisitCounts::default(),
2204            window(),
2205            stage("m"),
2206            setup().inference_config,
2207            crate::persistence::RunMetadata {
2208                run_id: "run-inmem".to_string(),
2209                agent_name: "a".to_string(),
2210                agent_path: "/p".to_string(),
2211                task: "t".to_string(),
2212                model: None,
2213                workdir: dir.path().to_string_lossy().to_string(),
2214                num_stages: 1,
2215                started_at: 0,
2216                parent_run_id: None,
2217                metadata: std::collections::HashMap::new(),
2218                callback_url: None,
2219                callback_secret: None,
2220                title: None,
2221                unattended: false,
2222                read_paths: None,
2223            },
2224            crate::persistence::TokenTotals::default(),
2225            crate::pipeline::PersistWatermark::default(),
2226            ReadyToInfer,
2227        ));
2228
2229        world.run_until_idle(20).await;
2230        world.flush_and_stop().await;
2231
2232        assert_eq!(world.agent_status(entity), Some(AgentStatus::Complete));
2233        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
2234    }
2235
2236    #[tokio::test]
2237    async fn world_init_and_restore_needs_no_daemon_infra() {
2238        // `PipelineWorld::new` + `restore::restore_agent` form a self-contained
2239        // spin-up→restore path: no control socket, HTTP server, PID files, or build
2240        // markers - only providers, a tool service, a runs dir, and a runtime. This
2241        // locks that in so the daemon wiring stays optional.
2242        use leviath_core::region::EntryKind;
2243        use leviath_core::run_meta::{ContextSnapshot, RegionEntrySnapshot, RegionSnapshot};
2244
2245        let dir = tempfile::tempdir().unwrap();
2246        let mut world = PipelineWorld::new(
2247            registry_with(vec![text("unused")]),
2248            Arc::new(EchoTools),
2249            InferencePoolConfig::new(),
2250            1,
2251            Some(dir.path().to_path_buf()),
2252            Handle::current(),
2253        );
2254        let entity = world.spawn_agent((
2255            AgentBlueprint(blueprint()),
2256            StageCursor { index: 0 },
2257            agent_state(),
2258            crate::components::MessageInbox::default(),
2259            StageProgress::default(),
2260            StageInferences(vec![stage("m")]),
2261            StageSetups(vec![setup()]),
2262            VisitCounts::default(),
2263            window(),
2264            stage("m"),
2265            setup().inference_config,
2266            crate::persistence::TokenTotals::default(),
2267        ));
2268
2269        let snapshot = ContextSnapshot {
2270            stage_name: "s0".to_string(),
2271            total_tokens: 4,
2272            max_tokens: 10_000,
2273            regions: vec![RegionSnapshot {
2274                name: "conversation".to_string(),
2275                kind: "clearable".to_string(),
2276                current_tokens: 4,
2277                max_tokens: 10_000,
2278                entries: vec![RegionEntrySnapshot {
2279                    content: "restored turn".to_string(),
2280                    tokens: 4,
2281                    kind: EntryKind::UserMessage,
2282                    metadata: None,
2283                    key: None,
2284                    taint: Default::default(),
2285                }],
2286            }],
2287        };
2288        crate::restore::restore_agent(
2289            world.world_mut(),
2290            entity,
2291            &snapshot,
2292            0,
2293            3,
2294            crate::persistence::TokenTotals::default(),
2295        );
2296
2297        let state = world
2298            .world()
2299            .get::<crate::components::AgentState>(entity)
2300            .unwrap();
2301        assert_eq!(state.status, AgentStatus::Active);
2302        assert_eq!(state.iteration, 3);
2303        let win = world
2304            .world()
2305            .get::<crate::components::ContextWindow>(entity)
2306            .unwrap();
2307        assert_eq!(
2308            win.get_region("conversation").unwrap().content[0].content,
2309            "restored turn"
2310        );
2311    }
2312
2313    #[tokio::test]
2314    async fn spawn_from_blueprint_errors_on_oversized_system_prompt() {
2315        let mut world = build_world(registry_with(vec![]));
2316        // A blueprint whose stage carries an enormous system prompt in a tiny
2317        // pinned region overflows at spawn.
2318        let layout = leviath_core::layout::ContextLayout::new(
2319            vec![leviath_core::layout::RegionDefinition::new(
2320                "task".to_string(),
2321                RegionKind::Pinned,
2322                50,
2323            )],
2324            1000,
2325        );
2326        let mut s = leviath_core::Stage::new(
2327            "s".to_string(),
2328            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
2329        );
2330        s.config.insert(
2331            "system_prompt".to_string(),
2332            serde_json::Value::String("x".repeat(100_000)),
2333        );
2334        let bp = leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout);
2335
2336        let err = world.spawn_from_blueprint(
2337            "a".to_string(),
2338            bp,
2339            "task",
2340            vec![crate::pipeline::ResolvedStage {
2341                provider_name: "script".to_string(),
2342                model: "m".to_string(),
2343                tools: vec![],
2344                fallbacks: Vec::new(),
2345            }],
2346            hints(true),
2347        );
2348        assert!(err.is_err());
2349    }
2350
2351    #[tokio::test]
2352    async fn wake_handle_and_run_until_idle_bound_are_exposed() {
2353        // Exercises the wake handle accessor and the max-waits safety bound on a
2354        // world with an agent parked on an in-flight inference that never
2355        // resolves within the bound (script returns after we stop waiting).
2356        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
2357        let _ = world.wake_handle();
2358        let e = spawn(&mut world);
2359        world.run_until_idle(0).await; // bound 0 ⇒ no extra waits
2360        // With no waits allowed we may not have observed completion yet; drain.
2361        world.run_until_idle(20).await;
2362        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
2363    }
2364}