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, gate_requires_children,
46    handle_empty_response, poll_dynamic_tool_refresh, process_response, reflect_interaction_status,
47    refresh_advertised_tools, require_context_regions, resolve_transition, sync_tool_stages,
48};
49use crate::providers::ProviderRegistry;
50use crate::tool_bridge::spawn_tool_pool;
51
52/// Counts of agents in each phase-marker - the world's per-tick "fingerprint".
53/// Two consecutive equal fingerprints mean a tick changed nothing (quiescence).
54type Fingerprint = [usize; 12];
55
56/// How many attributed system panics one [`PipelineWorld::run_to_fixed_point`]
57/// round will absorb before it stops driving. Each one fails a different agent,
58/// so this only bites if the world is thoroughly broken - it exists so a
59/// pathological agent can't spin the loop.
60const MAX_TICK_FAILURES_PER_ROUND: usize = 8;
61
62/// A schedule configured the way the pipeline needs it.
63///
64/// Every pipeline system is `.chain()`ed, so the multi-threaded executor can
65/// never overlap two of them - it only adds a hop through the compute task
66/// pool. Running single-threaded keeps systems on the thread that catches their
67/// panics, which is what lets [`run_isolated`] read the offending agent out of
68/// the (thread-local) [`crate::tick_scope`].
69fn tick_schedule() -> Schedule {
70    let mut schedule = Schedule::default();
71    // bevy_ecs 0.19 replaced `set_executor_kind(ExecutorKind::…)` with
72    // `set_executor(<executor instance>)`.
73    schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
74    schedule
75}
76
77/// What one [`PipelineWorld::tick`] did.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum TickOutcome {
80    /// Every system ran to completion.
81    Clean,
82    /// A system panicked and the agent responsible was failed; the rest of the
83    /// world is unaffected and can keep being driven.
84    AgentFailed,
85    /// A system panicked with no agent in scope, so nothing could be failed.
86    /// Re-ticking would just re-panic.
87    Unattributed,
88}
89
90/// The shared ECS world that hosts and drives every agent.
91pub struct PipelineWorld {
92    world: World,
93    schedule: Schedule,
94    wake: Arc<Notify>,
95    shutdown: Arc<Notify>,
96    msg_tx: UnboundedSender<AgentMessage>,
97    /// The tool worker pool tasks; kept so they live as long as the world. Each
98    /// exits on its own when the world (and thus the [`ToolStage`] sender) is
99    /// dropped. The pool size is the tool-lane concurrency cap.
100    _tool_tasks: Vec<JoinHandle<()>>,
101    /// The persistence worker task. Retained (rather than detached) so
102    /// [`Self::flush_and_stop`] can close its channel and `await` it, guaranteeing
103    /// every queued snapshot reaches disk before shutdown. `None` once flushed.
104    persist_task: Option<JoinHandle<()>>,
105}
106
107impl PipelineWorld {
108    /// Build a world: wire the pool/bridge resources, register the providers and
109    /// tool service, spawn the tool worker onto `runtime`, and assemble the tick
110    /// schedule. Agents are added later via [`Self::spawn_agent`].
111    pub fn new(
112        providers: ProviderRegistry,
113        tool_service: Arc<dyn ToolService>,
114        pool_config: InferencePoolConfig,
115        tool_concurrency: usize,
116        runs_dir: std::path::PathBuf,
117        runtime: Handle,
118    ) -> Self {
119        // `Query::par_iter` fans out over the compute task pool; initialize it
120        // once (idempotent) so per-agent request assembly in `dispatch_inference`
121        // runs in parallel. (The schedule executor itself is single-threaded -
122        // see `tick_schedule`.)
123        bevy_tasks::ComputeTaskPool::get_or_init(bevy_tasks::TaskPool::default);
124
125        let wake = Arc::new(Notify::new());
126        let shutdown = Arc::new(Notify::new());
127
128        let (inf_tx, inf_rx) = unbounded_channel();
129        let (trans_tx, trans_rx) = unbounded_channel();
130        let (compact_tx, compact_rx) = unbounded_channel();
131        let (tool_job_tx, tool_job_rx) = unbounded_channel();
132        let (tool_res_tx, tool_res_rx) = unbounded_channel();
133        let (persist_tx, persist_rx) = unbounded_channel();
134        let (msg_tx, msg_rx) = unbounded_channel();
135        let (ip_tx, ip_rx) = unbounded_channel();
136        let (gp_tx, gp_rx) = unbounded_channel();
137        let (cs_tx, cs_rx) = unbounded_channel();
138        let (title_tx, title_rx) = unbounded_channel();
139
140        let tool_tasks = spawn_tool_pool(
141            &runtime,
142            tool_job_rx,
143            tool_res_tx,
144            wake.clone(),
145            tool_concurrency,
146        );
147        // Retained so `flush_and_stop` can drain it on shutdown. Left to its own
148        // devices otherwise: it exits when the world (and thus its PersistenceStage
149        // sender) is dropped.
150        let persist_task = runtime.spawn(persistence_worker(runs_dir, persist_rx));
151        let ip_runtime = runtime.clone();
152        let gp_runtime = runtime.clone();
153
154        let mut world = World::new();
155        world.insert_resource(Providers(providers));
156        world.insert_resource(InferenceStage {
157            pools: Arc::new(InferencePools::new(pool_config)),
158            outcomes: inf_tx,
159            transition_outcomes: trans_tx,
160            compaction_outcomes: compact_tx,
161            content_summary_outcomes: cs_tx,
162            wake: wake.clone(),
163            runtime,
164            exact_token_counting: false,
165        });
166        world.insert_resource(crate::context_transform::ContentSummaryResults(cs_rx));
167        world.insert_resource(crate::title::TitleSink(title_tx));
168        world.insert_resource(crate::title::TitleResults(title_rx));
169        world.insert_resource(crate::interaction_points::InteractionPointStage {
170            outcomes: ip_tx,
171            wake: wake.clone(),
172            runtime: ip_runtime,
173        });
174        world.insert_resource(crate::interaction_points::InteractionPointResults(ip_rx));
175        world.insert_resource(crate::gate_prompt::GatePromptStage {
176            outcomes: gp_tx,
177            wake: wake.clone(),
178            runtime: gp_runtime,
179        });
180        world.insert_resource(crate::gate_prompt::GatePromptResults(gp_rx));
181        world.insert_resource(InferenceResults(inf_rx));
182        world.insert_resource(TransitionResults(trans_rx));
183        world.insert_resource(CompactionResults(compact_rx));
184        world.insert_resource(ToolServiceRes(tool_service));
185        world.insert_resource(ToolStage(tool_job_tx));
186        world.insert_resource(ToolResults(tool_res_rx));
187        world.insert_resource(PersistenceStage(persist_tx));
188        world.insert_resource(MessageIntake(msg_rx));
189        // Telemetry defaults to the no-op sink; a host that wants export
190        // replaces the resource after construction (as `build_host` does).
191        world.insert_resource(crate::telemetry::Telemetry(std::sync::Arc::new(
192            leviath_core::telemetry::NoopSink,
193        )));
194
195        // The tick chain is split into two `.chain()`ed groups (bevy caps a
196        // system tuple at 20); the second group runs strictly after the first.
197        let mut schedule = tick_schedule();
198        schedule.add_systems(
199            (
200                // First: stop whatever a now-terminal agent still has running in
201                // the async lanes. Ahead of everything else so a cancel frees its
202                // inference permit and tool-lane worker on the very next tick,
203                // rather than whenever the provider or tool happens to answer.
204                abort_terminal_work,
205                deliver_messages,
206                collect_compaction,
207                // Apply any completed Summarize context-transform summaries into
208                // the child's regions, then dispatch newly-queued ones.
209                crate::context_transform::collect_content_summary,
210                crate::context_transform::dispatch_content_summary,
211                // Route edge-transform compaction through the compaction lane
212                // before the threshold-based pass.
213                dispatch_edge_compact,
214                dispatch_compaction,
215                // Cap a stage at its max_iterations before running more inference.
216                enforce_max_iterations,
217                // …then the softer guard: bail out of a stage that is burning
218                // turns/edits without progress, when the blueprint declares a
219                // `stuck` escape edge. Runs after the hard cap so that always wins.
220                detect_stuck_stage,
221                // Stop a run whose working directory vanished, rather than let
222                // every tool fail with ENOENT for the rest of the run.
223                check_workspace_health,
224                // Tag dynamic_tools agents that have pending tool changes, then
225                // apply the re-advertisement before the next request is assembled
226                // so a newly-discovered tool is visible.
227                poll_dynamic_tool_refresh,
228                refresh_advertised_tools,
229                dispatch_inference,
230                collect_inference,
231                // Intercept a fan-out stage's split response before normal routing.
232                crate::fanout::fan_out_split,
233                process_response,
234                // Apply resolved taint gate prompts (re-arming ReadyForTools)
235                // before the tool dispatch re-runs the held batch.
236                crate::gate_prompt::collect_gate_prompt,
237                dispatch_tools,
238                collect_tools,
239                // Apply any resolved stage-boundary interaction-point answers
240                // before the stage decides its transition.
241                crate::interaction_points::collect_interaction_point,
242            )
243                .chain(),
244        );
245        schedule.add_systems(
246            (
247                handle_empty_response,
248                // Hold a `requires_children` stage until its sub-agents finish.
249                gate_requires_children,
250                // Re-run a stage that left a `required` context region empty
251                // before it may transition or ask for approval.
252                require_context_regions,
253                // Intercept a would-be transition for an interactive-points stage
254                // (e.g. plan_approval) and drive the interaction-point lane.
255                crate::interaction_points::gate_interaction_points,
256                crate::interaction_points::dispatch_interaction_point,
257                resolve_transition,
258                dispatch_transition_choice,
259                collect_transition_choice,
260                // Drive fan-out workers and merge once they finish.
261                crate::fanout::fan_out_collect,
262                // Narrate lifecycle/activity into the telemetry sink. Must run
263                // before `sync_tool_stages` (which consumes the transient
264                // `StageJustEntered` marker) and before `dispatch_persistence`
265                // (which drains the log buffer this system only reads).
266                crate::telemetry::observe_lifecycle,
267                sync_tool_stages,
268                // Store any finished run title, then start newly-marked ones.
269                // Collect precedes persistence so a landed title is written on
270                // this same tick.
271                crate::title::collect_title,
272                crate::title::dispatch_title,
273                // Mirror open interaction-hub requests into agent status
274                // (Active ↔ Waiting) so the dashboard surfaces blocked prompts;
275                // must run before persistence so the status change is written.
276                reflect_interaction_status,
277                dispatch_persistence,
278            )
279                .chain()
280                .after(crate::interaction_points::collect_interaction_point),
281        );
282
283        Self {
284            world,
285            schedule,
286            wake,
287            shutdown,
288            msg_tx,
289            _tool_tasks: tool_tasks,
290            persist_task: Some(persist_task),
291        }
292    }
293
294    /// Mutable access to the underlying ECS world, for spawning agents (the CLI /
295    /// daemon builds each agent's component bundle) and inspection.
296    pub fn world_mut(&mut self) -> &mut World {
297        &mut self.world
298    }
299
300    /// Read-only access to the underlying ECS world.
301    pub fn world(&self) -> &World {
302        &self.world
303    }
304
305    /// Enable (or disable) the opt-in exact pre-inference budget guard for this
306    /// world - see `inference_bridge::InferenceJob::exact_token_counting`.
307    /// Call once at startup when the run config requests it, before serving.
308    pub fn set_exact_token_counting(&mut self, enabled: bool) {
309        // `InferenceStage` is inserted by every `PipelineWorld::new` path, so it
310        // is a hard invariant here - `resource_mut` (which panics if absent) is
311        // correct and keeps this branch-free.
312        self.world
313            .resource_mut::<crate::pipeline::InferenceStage>()
314            .exact_token_counting = enabled;
315    }
316
317    /// Install the shared interaction hub as a world resource and attach this
318    /// world's wake handle to it, so opening/answering a prompt wakes the driver
319    /// and [`reflect_interaction_status`]
320    /// mirrors the change into agent status. Call once at startup, before
321    /// serving. Without this, that system is a no-op (test worlds).
322    pub fn insert_interaction_hub(&mut self, hub: crate::interaction_hub::InteractionHub) {
323        hub.attach_wake(self.wake.clone());
324        self.world.insert_resource(hub);
325    }
326
327    /// Spawn an agent from its pre-built component bundle and wake the driver so
328    /// the next fixed-point picks it up. Returns the new entity.
329    pub fn spawn_agent(&mut self, bundle: impl Bundle) -> Entity {
330        let e = self.world.spawn(bundle).id();
331        self.wake.notify_one();
332        e
333    }
334
335    /// Spawn an agent from a blueprint + task + per-stage resolution (see
336    /// [`crate::pipeline::spawn_agent`]) and wake the driver. Returns the new
337    /// entity, or an error if the first stage's system prompt doesn't fit.
338    pub fn spawn_from_blueprint(
339        &mut self,
340        agent_id: String,
341        blueprint: leviath_core::Blueprint,
342        task: &str,
343        stages: Vec<crate::pipeline::ResolvedStage>,
344        global_batch_tool_hint: bool,
345    ) -> Result<Entity, String> {
346        let e = crate::pipeline::spawn_agent(
347            &mut self.world,
348            agent_id,
349            blueprint,
350            task,
351            stages,
352            global_batch_tool_hint,
353        )?;
354        self.wake.notify_one();
355        Ok(e)
356    }
357
358    /// Deliver a message to a running agent (routed to its inbox on the next
359    /// tick) and wake the driver.
360    pub fn send_message(&self, msg: AgentMessage) -> Result<(), ProviderError> {
361        self.msg_tx
362            .send(msg)
363            .map_err(|e| ProviderError::Other(format!("world message channel closed: {e}")))?;
364        self.wake.notify_one();
365        Ok(())
366    }
367
368    /// A clone of the wake handle, so external producers (e.g. a control socket)
369    /// can nudge the driver after mutating the world directly.
370    pub fn wake_handle(&self) -> Arc<Notify> {
371        self.wake.clone()
372    }
373
374    /// Request the [`Self::run`] loop to stop after its current fixed point.
375    pub fn shutdown(&self) {
376        self.shutdown.notify_one();
377    }
378
379    /// A clone of the shutdown handle, so a supervisor can stop a [`Self::run`]
380    /// loop that has taken ownership of the world on another task.
381    pub fn shutdown_handle(&self) -> Arc<Notify> {
382        self.shutdown.clone()
383    }
384
385    /// Cleanly stop the world, guaranteeing every queued snapshot reaches disk.
386    ///
387    /// The persistence lane is async and fire-and-forget, so a plain shutdown (the
388    /// [`Self::run`]/`serve` loop returning, then the world dropping) can lose
389    /// snapshots still queued in the channel. This method closes that gap: it
390    /// signals shutdown, drives one last fixed point so any state that settled
391    /// after the loop parked is dispatched to the lane, then **closes the lane and
392    /// awaits the worker** so all queued writes (`meta.json` / `context.json` /
393    /// `run.lvr`) land before it returns.
394    ///
395    /// Call it after the serve loop has returned (the tokio runtime must still be
396    /// alive for the worker to be scheduled). Idempotent: a second call is a no-op
397    /// because the persistence resource is already removed and the task taken.
398    pub async fn flush_and_stop(&mut self) {
399        // Idempotent - the serve loop has usually already returned on this signal.
400        self.shutdown.notify_one();
401        // Dispatch anything that settled between the last park and now (e.g. an
402        // inference result that woke the loop the same instant shutdown fired).
403        self.run_to_fixed_point();
404        // Drop the *only* `PersistJob` sender so the worker's `recv()` loop drains
405        // its queue and then ends.
406        self.world.remove_resource::<PersistenceStage>();
407        // Wait for every queued write to hit disk.
408        if let Some(task) = self.persist_task.take() {
409            let _ = task.await;
410        }
411        // Push any buffered telemetry export out before the process goes away;
412        // the final fixed point above already emitted the last events. The
413        // resource always exists - `new()` installs the no-op default.
414        self.world
415            .resource::<crate::telemetry::Telemetry>()
416            .0
417            .force_flush();
418    }
419
420    /// The status of an agent, if it still exists.
421    pub fn agent_status(&self, entity: Entity) -> Option<AgentStatus> {
422        self.world
423            .get::<AgentState>(entity)
424            .map(|s| s.status.clone())
425    }
426
427    /// Set an agent's status and wake the driver. Returns `false` if the agent no
428    /// longer exists. The async-starting dispatchers only act on `Active` agents,
429    /// so this is how the world pauses/resumes/cancels an agent - a non-`Active`
430    /// agent is simply data the systems skip until it is `Active` again.
431    pub fn set_status(&mut self, entity: Entity, status: AgentStatus) -> bool {
432        let Some(mut state) = self.world.get_mut::<AgentState>(entity) else {
433            return false;
434        };
435        state.status = status;
436        self.wake.notify_one();
437        true
438    }
439
440    /// Pause an agent (it finishes any in-flight step, then stops before starting
441    /// new work). Returns `false` if the agent no longer exists.
442    pub fn pause(&mut self, entity: Entity) -> bool {
443        self.set_status(entity, AgentStatus::Idle)
444    }
445
446    /// Resume a paused agent.
447    pub fn resume(&mut self, entity: Entity) -> bool {
448        self.set_status(entity, AgentStatus::Active)
449    }
450
451    /// Cancel an agent (it stops starting new work; in-flight results still land).
452    pub fn cancel(&mut self, entity: Entity) -> bool {
453        self.set_status(entity, AgentStatus::Cancelled)
454    }
455
456    /// Run one schedule tick over every agent, catching a panic from any system
457    /// so one bad agent can't crash the daemon and take every other hosted agent
458    /// with it.
459    ///
460    /// When the panic can be traced to a specific agent (the usual case - see
461    /// `tick_scope`), that agent is failed with the panic message so it
462    /// stops being driven, its run is persisted as errored, and the host reaps
463    /// it. Without that, the world would re-tick the same unchanged state on
464    /// every wake and panic again indefinitely.
465    pub fn tick(&mut self) -> TickOutcome {
466        let Err(panicked) = run_isolated(&mut self.schedule, &mut self.world) else {
467            // A clean unwind doesn't mean a clean tick: work that ran on the
468            // compute pool catches its own panics, since they can't unwind back
469            // here, and leaves a marker instead.
470            return self.fail_agents_panicked_in_parallel();
471        };
472        let message = panic_status_message(&panicked.message);
473        match panicked.entity {
474            Some(entity) if self.set_status(entity, AgentStatus::Error { message }) => {
475                tracing::error!(
476                    ?entity,
477                    panic = %panicked.message,
478                    "a pipeline system panicked; failing that agent - the daemon and every \
479                     other run keep going"
480                );
481                TickOutcome::AgentFailed
482            }
483            _ => {
484                tracing::error!(
485                    panic = %panicked.message,
486                    "a pipeline system panicked outside any agent's scope; the daemon survived \
487                     (an agent may be wedged - cancel it via `lev cancel <run-id>`)"
488                );
489                TickOutcome::Unattributed
490            }
491        }
492    }
493
494    /// Fail every agent that a compute-pool body marked
495    /// [`PanickedInParallel`](crate::tick_scope::PanickedInParallel), and report
496    /// whether there were any.
497    ///
498    /// These panics were caught on a task-pool thread rather than unwinding into
499    /// `tick`, so the marker component is how they reach the driver - but from
500    /// here on they are handled exactly like an attributed unwind: the agent is
501    /// failed, stops being driven, and its run persists as errored.
502    fn fail_agents_panicked_in_parallel(&mut self) -> TickOutcome {
503        let mut query = self
504            .world
505            .query::<(Entity, &crate::tick_scope::PanickedInParallel)>();
506        let failed: Vec<(Entity, String)> = query
507            .iter(&self.world)
508            .map(|(entity, p)| (entity, p.message.clone()))
509            .collect();
510        if failed.is_empty() {
511            return TickOutcome::Clean;
512        }
513        for (entity, message) in failed {
514            self.world
515                .entity_mut(entity)
516                .remove::<crate::tick_scope::PanickedInParallel>();
517            let status = AgentStatus::Error {
518                message: panic_status_message(&message),
519            };
520            // The entity came straight out of the query above, so it exists.
521            let _ = self.set_status(entity, status);
522        }
523        TickOutcome::AgentFailed
524    }
525
526    /// Append a system to the schedule (test-only, for panic-isolation tests).
527    #[cfg(test)]
528    pub(crate) fn add_test_system<M>(
529        &mut self,
530        // `IntoSystemConfigs` became `IntoScheduleConfigs<ScheduleSystem, _>` in
531        // bevy_ecs 0.19 (it now also describes observer and other schedulables,
532        // so the schedulable kind is an explicit parameter).
533        system: impl bevy_ecs::schedule::IntoScheduleConfigs<bevy_ecs::system::ScheduleSystem, M>,
534    ) {
535        self.schedule.add_systems(system);
536    }
537
538    fn count<F: QueryFilter>(&mut self) -> usize {
539        let mut q = self.world.query_filtered::<(), F>();
540        q.iter(&self.world).count()
541    }
542
543    /// Snapshot the per-phase marker counts.
544    fn fingerprint(&mut self) -> Fingerprint {
545        [
546            self.count::<With<ReadyToInfer>>(),
547            self.count::<With<AwaitingInference>>(),
548            self.count::<With<ProcessResponse>>(),
549            self.count::<With<ReadyForTools>>(),
550            self.count::<With<ReadyForTransition>>(),
551            self.count::<With<ResolveTransition>>(),
552            self.count::<With<AwaitingTools>>(),
553            self.count::<With<AwaitingTransitionChoice>>(),
554            self.count::<With<AwaitingTransitionResponse>>(),
555            self.count::<With<AwaitingCompaction>>(),
556            self.count::<With<crate::title::PendingTitle>>(),
557            self.count::<With<crate::title::AwaitingTitle>>(),
558        ]
559    }
560
561    /// Any agent waiting on an in-flight async job (inference, tools, a
562    /// transition choice, or compaction) whose completion will wake the driver.
563    fn has_async_inflight(&mut self) -> bool {
564        self.count::<With<AwaitingInference>>() > 0
565            || self.count::<With<AwaitingTools>>() > 0
566            || self.count::<With<AwaitingTransitionResponse>>() > 0
567            || self.count::<With<AwaitingCompaction>>() > 0
568            || self.count::<With<crate::title::AwaitingTitle>>() > 0
569    }
570
571    /// Drive the schedule until a tick changes nothing (quiescence). Public so a
572    /// host loop can interleave control operations between quiescent points.
573    pub fn run_to_fixed_point(&mut self) {
574        let mut prev = self.fingerprint();
575        let mut failures = 0;
576        loop {
577            let outcome = self.tick();
578            match outcome {
579                TickOutcome::Clean => {}
580                // The offending agent has been failed, so it won't be driven
581                // again. Keep ticking: the rest of the world still has work to
582                // do, and only a later tick reaches `dispatch_persistence` (the
583                // last system in the chain) to record the failure on disk. The
584                // budget stops a pathological agent that somehow panics again
585                // from spinning this loop.
586                TickOutcome::AgentFailed if failures < MAX_TICK_FAILURES_PER_ROUND => {
587                    failures += 1;
588                }
589                // Nothing to fail, so re-ticking would just re-panic: stop
590                // driving this round. The daemon stays alive, other agents keep
591                // running, and a wedged agent can be cancelled via the control
592                // socket (dispatch systems skip non-Active agents once
593                // cancelled).
594                TickOutcome::AgentFailed | TickOutcome::Unattributed => break,
595            }
596            let now = self.fingerprint();
597            // Quiescence, but only trust it after a clean tick: a panicking tick
598            // abandons the rest of the chain (and its buffered commands), so the
599            // markers can look unchanged while the world very much has changed.
600            // Force at least one more tick so the failed agent gets persisted.
601            if now == prev && outcome == TickOutcome::Clean {
602                break;
603            }
604            prev = now;
605        }
606    }
607
608    /// Drive every agent as far as it can go **right now**, then, while async
609    /// work is in flight, wait for each completion and drive again - returning
610    /// once the world is fully quiescent with nothing in flight. Bounded by
611    /// `max_waits` wake-waits as a safety valve so a lost/never-arriving wake
612    /// can't hang a caller (e.g. a test) forever.
613    pub async fn run_until_idle(&mut self, max_waits: usize) {
614        self.run_to_fixed_point();
615        let mut waits = 0;
616        while self.has_async_inflight() && waits < max_waits {
617            self.wake.notified().await;
618            waits += 1;
619            self.run_to_fixed_point();
620        }
621    }
622
623    /// Run forever: drive to quiescence, then park until an async completion or
624    /// an external `send_message`/`spawn_agent` wakes the driver. Returns when
625    /// [`Self::shutdown`] is signalled.
626    pub async fn run(&mut self) {
627        loop {
628            self.run_to_fixed_point();
629            tokio::select! {
630                _ = self.wake.notified() => {}
631                _ = self.shutdown.notified() => return,
632            }
633        }
634    }
635}
636
637/// How a caught panic is recorded on the agent it is blamed on. Shared by the
638/// unwind path and the compute-pool path so a run's `error` reads the same
639/// either way.
640fn panic_status_message(panic: &str) -> String {
641    format!("internal error: a pipeline system panicked: {panic}")
642}
643
644/// A panic caught while ticking the schedule, and the agent it belongs to.
645struct TickPanic {
646    /// The agent being processed when the panic fired, if the pipeline had
647    /// recorded one (see [`crate::tick_scope`]).
648    entity: Option<Entity>,
649    /// The panic payload rendered as text.
650    message: String,
651}
652
653/// Run a schedule over a world, catching a panic from any system so it can't
654/// unwind the daemon's drive loop and take down every hosted agent.
655///
656/// The world may be partially updated after a panic: the panicking system's
657/// buffered `Commands` are lost, but resources and components already written
658/// are intact, so the caller can still fail the offending agent.
659fn run_isolated(schedule: &mut Schedule, world: &mut World) -> Result<(), TickPanic> {
660    // Clear first: the slot is thread-local and survives across ticks, so a
661    // stale entity from an earlier tick must not be blamed for this one.
662    crate::tick_scope::clear();
663    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| schedule.run(world))) {
664        Ok(()) => Ok(()),
665        Err(payload) => {
666            reset_executor(schedule);
667            Err(TickPanic {
668                entity: crate::tick_scope::current(),
669                message: leviath_core::panic_message(payload.as_ref()),
670            })
671        }
672    }
673}
674
675/// Give `schedule` a fresh executor after a caught panic.
676///
677/// bevy's executors mark a system "completed" *before* running it and only
678/// clear that set when `run` returns normally. A panic therefore leaves every
679/// system up to and including the offending one marked done, so the **next**
680/// tick silently skips them and only runs the tail of the chain - a partial
681/// tick that would, among other things, keep `dispatch_persistence` from ever
682/// seeing an agent we just failed. Swapping the executor kind and back is the
683/// public API for forcing a rebuild.
684///
685/// One call suffices on bevy_ecs 0.19: `set_executor` takes an executor
686/// *instance* and unconditionally replaces `schedule.executor` with it (clearing
687/// `executor_initialized` too), so the fresh `SingleThreadedExecutor` arrives
688/// with an empty `completed_systems`.
689///
690/// On 0.15 this had to set two different *kinds* and swap back, because
691/// `set_executor_kind` was a no-op when the kind was unchanged - and
692/// `SimpleExecutor`, the other kind it used, no longer exists.
693fn reset_executor(schedule: &mut Schedule) {
694    schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    /// Serializes every test in this binary that swaps the **process-global**
702    /// panic hook - see the definition for why they can't run concurrently.
703    use crate::test_support::PANIC_HOOK_LOCK;
704
705    /// Run `f` with the process panic hook silenced (the panic is expected), and
706    /// serialized against the other hook-swapping tests.
707    fn with_silent_panics<T>(f: impl FnOnce() -> T) -> T {
708        let _hook_guard = PANIC_HOOK_LOCK
709            .lock()
710            .unwrap_or_else(std::sync::PoisonError::into_inner);
711        let prev_hook = std::panic::take_hook();
712        std::panic::set_hook(Box::new(|_| {}));
713        let out = f();
714        std::panic::set_hook(prev_hook);
715        out
716    }
717
718    #[test]
719    fn run_isolated_catches_a_system_panic_and_reports_the_agent() {
720        fn ok_system() {}
721        fn boom_system() {
722            panic!("simulated system panic");
723        }
724        // A system that panics *while working on a specific agent* - the shape
725        // every real pipeline system has.
726        fn boom_on_agent_system() {
727            crate::tick_scope::enter(
728                Entity::from_raw_u32(41)
729                    .expect("a small literal index is always a valid entity id"),
730            );
731            panic!("agent-scoped panic");
732        }
733        let mut world = World::new();
734
735        // A clean schedule ticks normally.
736        let mut ok = tick_schedule();
737        ok.add_systems(ok_system);
738        assert!(run_isolated(&mut ok, &mut world).is_ok());
739
740        // A panicking system is caught (the daemon would survive) and, with no
741        // agent in scope, reports no entity to blame.
742        let mut bad = tick_schedule();
743        bad.add_systems(boom_system);
744        let err = with_silent_panics(|| run_isolated(&mut bad, &mut world))
745            .expect_err("the panic must be caught");
746        assert_eq!(err.entity, None);
747        assert_eq!(err.message, "simulated system panic");
748
749        // With an agent in scope, the panic is attributed to it.
750        let mut blamed = tick_schedule();
751        blamed.add_systems(boom_on_agent_system);
752        let err = with_silent_panics(|| run_isolated(&mut blamed, &mut world))
753            .expect_err("the panic must be caught");
754        assert_eq!(
755            err.entity,
756            Some(
757                Entity::from_raw_u32(41)
758                    .expect("a small literal index is always a valid entity id")
759            )
760        );
761        assert_eq!(err.message, "agent-scoped panic");
762
763        // A later clean tick must not inherit the previous tick's entity.
764        assert!(run_isolated(&mut ok, &mut world).is_ok());
765        assert_eq!(crate::tick_scope::current(), None);
766    }
767
768    use crate::components::{AgentState, ContextWindow, InferenceConfig};
769    use crate::pipeline::{
770        AgentBlueprint, MessageIntake, StageCursor, StageInference, StageInferences, StageProgress,
771        StageSetup, StageSetups, VisitCounts,
772    };
773    use crate::tool_bridge::BoxedToolExec;
774    use leviath_core::{Region, RegionKind};
775    use leviath_providers::{
776        FinishReason, InferenceRequest, InferenceResponse, ModelCapabilities, Provider, TokenUsage,
777        ToolCall,
778    };
779    use std::sync::Mutex;
780
781    /// A provider scripted with a queue of responses; each `infer` pops the next.
782    struct Script {
783        responses: Mutex<std::collections::VecDeque<InferenceResponse>>,
784    }
785
786    #[async_trait::async_trait]
787    impl Provider for Script {
788        async fn infer(
789            &self,
790            _req: InferenceRequest,
791        ) -> leviath_providers::Result<InferenceResponse> {
792            let next = self.responses.lock().unwrap().pop_front();
793            next.ok_or_else(|| ProviderError::Other("script exhausted".to_string()))
794        }
795        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
796            1
797        }
798        fn max_context_tokens(&self, _m: &str) -> usize {
799            100_000
800        }
801        fn name(&self) -> &str {
802            "script"
803        }
804        fn capabilities(&self, _m: &str) -> ModelCapabilities {
805            ModelCapabilities::default()
806        }
807    }
808
809    fn text(content: &str) -> InferenceResponse {
810        InferenceResponse {
811            content: content.to_string(),
812            tool_calls: vec![],
813            tokens_used: TokenUsage {
814                prompt_tokens: 1,
815                completion_tokens: 1,
816                total_tokens: 2,
817                cached_tokens: 0,
818                cache_write_tokens: 0,
819            },
820            finish_reason: FinishReason::Complete,
821        }
822    }
823
824    fn with_tool(id: &str, name: &str) -> InferenceResponse {
825        let mut r = text("");
826        r.tool_calls.push(ToolCall {
827            id: id.to_string(),
828            name: name.to_string(),
829            arguments: serde_json::json!({}),
830            thought_signature: None,
831        });
832        r
833    }
834
835    /// A tool service that returns a fixed result string for every call.
836    struct EchoTools;
837    impl ToolService for EchoTools {
838        fn exec_for(&self, _entity: Entity, calls: Vec<ToolCall>) -> BoxedToolExec {
839            Box::new(move || {
840                Box::pin(async move {
841                    calls
842                        .into_iter()
843                        .map(|c| (c.id, "ok".to_string()))
844                        .collect()
845                })
846            })
847        }
848    }
849
850    fn window() -> ContextWindow {
851        let mut w = ContextWindow::new(10_000);
852        w.add_region(Region::new("sys".to_string(), RegionKind::Pinned, 2000));
853        w.add_region(Region::new(
854            "conversation".to_string(),
855            RegionKind::Clearable,
856            10_000,
857        ));
858        w.add_region(Region::new(
859            "tool_results".to_string(),
860            RegionKind::Temporary,
861            5000,
862        ));
863        w
864    }
865
866    fn agent_state() -> AgentState {
867        AgentState {
868            agent_id: "a".to_string(),
869            current_stage: "s".to_string(),
870            iteration: 0,
871            status: AgentStatus::Active,
872            spawned_children_ids: vec![],
873            pending_wait: None,
874            accepts_messages: true,
875        }
876    }
877
878    /// A stage advertising the tools the scripted responses here actually call.
879    ///
880    /// Advertising them is load-bearing: dispatch refuses tools a stage never
881    /// offered, so with an empty tool list every end-to-end test that drives a
882    /// tool call would short-circuit into a refusal and the tool service would
883    /// never be reached at all.
884    fn stage(model: &str) -> StageInference {
885        StageInference {
886            provider_name: "script".to_string(),
887            model: model.to_string(),
888            tools: ["do", "read"]
889                .iter()
890                .map(|n| leviath_providers::Tool {
891                    name: (*n).to_string(),
892                    description: String::new(),
893                    parameters: serde_json::json!({}),
894                })
895                .collect(),
896            tool_filter: None,
897        }
898    }
899
900    fn setup() -> StageSetup {
901        StageSetup {
902            inference_config: InferenceConfig {
903                temperature: None,
904                max_output_tokens: None,
905                extra_params: Default::default(),
906                batch_tool_hint: false,
907                request_timeout_secs: None,
908            },
909            routing: None,
910            accepts_messages: true,
911            context_layout: None,
912            system_prompt: None,
913        }
914    }
915
916    fn blueprint() -> leviath_core::Blueprint {
917        let layout = leviath_core::layout::ContextLayout::new(
918            vec![leviath_core::layout::RegionDefinition::new(
919                "conversation".to_string(),
920                RegionKind::Clearable,
921                10_000,
922            )],
923            12_000,
924        );
925        let s = leviath_core::Stage::new(
926            "s".to_string(),
927            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
928        );
929        leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
930    }
931
932    /// Spawn a single-stage agent, initially ready to infer.
933    fn spawn(world: &mut PipelineWorld) -> Entity {
934        world.spawn_agent((
935            AgentBlueprint(blueprint()),
936            StageCursor { index: 0 },
937            agent_state(),
938            crate::components::MessageInbox::default(),
939            StageProgress::default(),
940            StageInferences(vec![stage("m")]),
941            StageSetups(vec![setup()]),
942            VisitCounts::default(),
943            window(),
944            stage("m"),
945            setup().inference_config,
946            ReadyToInfer,
947        ))
948    }
949
950    fn build_world(providers: ProviderRegistry) -> PipelineWorld {
951        // These agents carry no RunMetadata, so persistence never fires and the
952        // runs dir is never written; any path is fine.
953        PipelineWorld::new(
954            providers,
955            Arc::new(EchoTools),
956            InferencePoolConfig::new(),
957            1,
958            std::env::temp_dir(),
959            Handle::current(),
960        )
961    }
962
963    #[tokio::test]
964    async fn set_exact_token_counting_toggles_the_stage_flag() {
965        let mut world = build_world(ProviderRegistry::new());
966        // Default is off.
967        assert!(
968            !world
969                .world()
970                .resource::<crate::pipeline::InferenceStage>()
971                .exact_token_counting
972        );
973        world.set_exact_token_counting(true);
974        assert!(
975            world
976                .world()
977                .resource::<crate::pipeline::InferenceStage>()
978                .exact_token_counting
979        );
980    }
981
982    #[tokio::test]
983    async fn run_to_fixed_point_survives_a_panicking_system() {
984        // A system that panics must not hang or crash the drive loop - it's
985        // caught and the loop breaks (the daemon survives).
986        fn boom_system() {
987            panic!("simulated system panic");
988        }
989        let mut world = build_world(ProviderRegistry::new());
990        world.add_test_system(boom_system);
991        // Unattributed: nothing to fail, so the round stops immediately.
992        with_silent_panics(|| world.run_to_fixed_point());
993    }
994
995    #[tokio::test]
996    async fn a_panic_on_the_compute_pool_is_attributed_to_its_agent() {
997        // `dispatch_inference` fans its per-agent work out over the compute task
998        // pool, where the thread-local scope can't reach the driver thread that
999        // catches unwinds. Those bodies run under `run_agent_parallel`, which
1000        // catches on the pool thread and marks the agent instead - this proves
1001        // the marker makes it back and fails the right run (issue #109).
1002        fn boom_in_parallel(
1003            agents: Query<(Entity, &AgentState)>,
1004            par_commands: bevy_ecs::system::ParallelCommands,
1005        ) {
1006            agents.par_iter().for_each(|(entity, state)| {
1007                if state.status != AgentStatus::Active {
1008                    return; // already failed - nothing left to blow up
1009                }
1010                // Clear the thread-local first: whatever attributes this panic,
1011                // it is demonstrably not the `enter`/`current` mechanism.
1012                crate::tick_scope::clear();
1013                crate::tick_scope::run_agent_parallel(entity, &par_commands, &mut || {
1014                    panic!("blew up on the compute pool");
1015                });
1016            });
1017        }
1018
1019        let mut world = build_world(ProviderRegistry::new());
1020        let entity = spawn(&mut world);
1021        world.add_test_system(boom_in_parallel);
1022        with_silent_panics(|| world.run_to_fixed_point());
1023
1024        let status = world.agent_status(entity);
1025        assert!(
1026            matches!(status, Some(AgentStatus::Error { ref message })
1027                if message.contains("a pipeline system panicked")
1028                    && message.contains("blew up on the compute pool")),
1029            "got: {status:?}"
1030        );
1031        // The marker is consumed, so a later tick doesn't re-fail the agent.
1032        assert!(
1033            world
1034                .world()
1035                .entity(entity)
1036                .get::<crate::tick_scope::PanickedInParallel>()
1037                .is_none(),
1038            "the marker must be drained once acted on"
1039        );
1040    }
1041
1042    #[tokio::test]
1043    async fn a_panicking_system_fails_its_agent_instead_of_looping_forever() {
1044        // Before issue #109 was fixed, a panicking system was swallowed
1045        // anonymously: nothing changed, so the very next wake re-ticked the same
1046        // state and panicked again, forever, while every other agent stalled.
1047        // Now the agent in scope is failed, which takes it out of the dispatch
1048        // systems (they only act on `Active` agents) and lets the world settle.
1049        static VICTIM: std::sync::Mutex<Option<Entity>> = std::sync::Mutex::new(None);
1050        static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1051
1052        fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1053            // No trailing statements after the `panic!`: an unreachable tail
1054            // would read as uncovered under the workspace's 100% gate.
1055            let Some((entity, _)) = agents
1056                .iter()
1057                .find(|(_, state)| state.status == AgentStatus::Active)
1058            else {
1059                return; // the agent has been failed - nothing left to blow up
1060            };
1061            crate::tick_scope::enter(entity);
1062            *VICTIM
1063                .lock()
1064                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(entity);
1065            PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1066            panic!("blew up on this agent");
1067        }
1068
1069        let mut world = build_world(ProviderRegistry::new());
1070        let entity = spawn(&mut world);
1071        world.add_test_system(boom_on_active_agent);
1072        with_silent_panics(|| world.run_to_fixed_point());
1073
1074        let victim = VICTIM
1075            .lock()
1076            .unwrap_or_else(std::sync::PoisonError::into_inner)
1077            .take();
1078        assert_eq!(victim, Some(entity), "the system saw the spawned agent");
1079        let status = world.agent_status(entity);
1080        assert!(
1081            matches!(status, Some(AgentStatus::Error { ref message })
1082                if message.contains("a pipeline system panicked")
1083                    && message.contains("blew up on this agent")),
1084            "got: {status:?}"
1085        );
1086        // The loop terminated rather than re-panicking without bound.
1087        assert!(
1088            PANICS.load(std::sync::atomic::Ordering::SeqCst) <= MAX_TICK_FAILURES_PER_ROUND + 1,
1089            "the panic budget must stop the round"
1090        );
1091    }
1092
1093    fn registry_with(responses: Vec<InferenceResponse>) -> ProviderRegistry {
1094        let mut r = ProviderRegistry::new();
1095        r.register(
1096            "script".to_string(),
1097            Arc::new(Script {
1098                responses: Mutex::new(responses.into_iter().collect()),
1099            }),
1100        );
1101        r
1102    }
1103
1104    #[tokio::test]
1105    async fn agent_completes_after_nudges_exhausted() {
1106        // Text-only responses with no tool calls get nudged up to the max; the
1107        // response after the last nudge is accepted and the single-stage
1108        // blueprint terminates the agent. (Exercises the handle_empty_response
1109        // nudge loop end-to-end through the driver.)
1110        let mut world = build_world(registry_with(vec![
1111            text("thinking"),
1112            text("still"),
1113            text("more"),
1114            text("final"),
1115        ]));
1116        let e = spawn(&mut world);
1117
1118        world.run_until_idle(30).await;
1119
1120        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1121    }
1122
1123    #[tokio::test]
1124    async fn agent_runs_tools_then_completes() {
1125        // First response calls a tool; after the tool result comes back the
1126        // second response is text-only, finishing the run.
1127        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1128        let e = spawn(&mut world);
1129
1130        world.run_until_idle(20).await;
1131
1132        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1133        // With no routing configured, tool results land in the conversation
1134        // region.
1135        assert!(
1136            world
1137                .world()
1138                .get::<ContextWindow>(e)
1139                .unwrap()
1140                .get_region("conversation")
1141                .unwrap()
1142                .current_tokens
1143                > 0
1144        );
1145    }
1146
1147    #[tokio::test]
1148    async fn insert_interaction_hub_installs_resource_and_attaches_wake() {
1149        use crate::dynamic_interaction::InteractionBackend;
1150        use crate::interaction_hub::InteractionHub;
1151        let mut world = build_world(registry_with(vec![]));
1152        let hub = InteractionHub::new();
1153        world.insert_interaction_hub(hub.clone());
1154
1155        // The hub is now a world resource the reflect system reads.
1156        assert!(world.world().get_resource::<InteractionHub>().is_some());
1157
1158        // The wake handle was attached: opening a request nudges the same wake
1159        // the driver parks on (a later notified() returns immediately).
1160        let backend = hub.backend_for("x");
1161        let asking = tokio::spawn(async move {
1162            backend
1163                .ask(leviath_core::interaction::InteractionRequest::free_text(
1164                    "q", "p", "s", true,
1165                ))
1166                .await
1167        });
1168        for _ in 0..8 {
1169            tokio::task::yield_now().await;
1170        }
1171        world.wake_handle().notified().await;
1172        hub.cancel("q");
1173        let _ = asking.await;
1174    }
1175
1176    #[tokio::test]
1177    async fn provider_error_marks_agent_error() {
1178        // Empty script ⇒ the very first infer errors.
1179        let mut world = build_world(registry_with(vec![]));
1180        let e = spawn(&mut world);
1181
1182        world.run_until_idle(20).await;
1183
1184        assert_eq!(
1185            std::mem::discriminant(&world.agent_status(e).unwrap()),
1186            std::mem::discriminant(&AgentStatus::Error {
1187                message: String::new()
1188            })
1189        );
1190    }
1191
1192    #[tokio::test]
1193    async fn send_message_reaches_the_agent_inbox() {
1194        // No responses queued: the agent dispatches inference and parks awaiting
1195        // it. We deliver a message; the deliver system routes it to context.
1196        let mut world = build_world(registry_with(vec![]));
1197        let e = spawn(&mut world);
1198        // Drive to the point the first (doomed) inference is dispatched/collected.
1199        world.run_until_idle(20).await;
1200
1201        world
1202            .send_message(AgentMessage {
1203                agent_id: "a".to_string(),
1204                content: "hello".to_string(),
1205                target_region: Some("conversation".to_string()),
1206                priority: 0,
1207            })
1208            .unwrap();
1209        world.tick(); // deliver_messages runs
1210
1211        assert!(
1212            world
1213                .world()
1214                .get::<ContextWindow>(e)
1215                .unwrap()
1216                .get_region("conversation")
1217                .unwrap()
1218                .current_tokens
1219                > 0
1220        );
1221    }
1222
1223    #[tokio::test]
1224    async fn run_returns_on_shutdown() {
1225        let mut world = build_world(registry_with(vec![text("done")]));
1226        spawn(&mut world);
1227        world.shutdown(); // pre-signal: run parks then returns
1228        // Must return rather than loop forever.
1229        world.run().await;
1230    }
1231
1232    #[tokio::test]
1233    async fn run_wakes_then_shuts_down() {
1234        // Drives run() on its own task: a wake makes it loop once (wake branch),
1235        // then a shutdown makes it return (shutdown branch).
1236        let mut world = build_world(registry_with(vec![
1237            text("t1"),
1238            text("t2"),
1239            text("t3"),
1240            text("t4"),
1241        ]));
1242        spawn(&mut world);
1243        let wake = world.wake_handle();
1244        let shutdown = world.shutdown_handle();
1245        let handle = tokio::spawn(async move { world.run().await });
1246
1247        wake.notify_one();
1248        tokio::task::yield_now().await;
1249        shutdown.notify_one();
1250
1251        handle.await.unwrap(); // returns cleanly
1252    }
1253
1254    #[tokio::test]
1255    async fn send_message_errors_when_intake_dropped() {
1256        let mut world = build_world(registry_with(vec![]));
1257        // Drop the intake receiver via the world accessor, closing the channel.
1258        let removed = world.world_mut().remove_resource::<MessageIntake>();
1259        drop(removed);
1260
1261        let err = world.send_message(AgentMessage {
1262            agent_id: "a".to_string(),
1263            content: "x".to_string(),
1264            target_region: None,
1265            priority: 0,
1266        });
1267        assert!(err.is_err());
1268    }
1269
1270    #[tokio::test]
1271    async fn script_provider_metadata_is_exercised() {
1272        // Keep the mock's non-`infer`/`capabilities` methods measured.
1273        let p = Script {
1274            responses: Mutex::new(std::collections::VecDeque::new()),
1275        };
1276        assert_eq!(p.name(), "script");
1277        assert_eq!(p.count_tokens("t", "m").await, 1);
1278        assert_eq!(p.max_context_tokens("m"), 100_000);
1279        let _ = p.capabilities("m");
1280    }
1281
1282    #[tokio::test]
1283    async fn agent_status_is_none_for_unknown_entity() {
1284        let world = build_world(registry_with(vec![]));
1285        assert_eq!(
1286            world.agent_status(
1287                Entity::from_raw_u32(999)
1288                    .expect("a small literal index is always a valid entity id")
1289            ),
1290            None
1291        );
1292    }
1293
1294    #[tokio::test]
1295    async fn paused_agent_does_not_progress_until_resumed() {
1296        let mut world = build_world(registry_with(vec![
1297            text("t1"),
1298            text("t2"),
1299            text("t3"),
1300            text("t4"),
1301        ]));
1302        let e = spawn(&mut world);
1303        assert!(world.pause(e));
1304
1305        world.run_until_idle(30).await;
1306        // Paused ⇒ parked as Idle, never inferred.
1307        assert_eq!(world.agent_status(e), Some(AgentStatus::Idle));
1308
1309        assert!(world.resume(e));
1310        world.run_until_idle(30).await;
1311        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1312    }
1313
1314    #[tokio::test]
1315    async fn cancelled_agent_stops_progressing() {
1316        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1317        let e = spawn(&mut world);
1318        assert!(world.cancel(e));
1319
1320        world.run_until_idle(20).await;
1321
1322        assert_eq!(world.agent_status(e), Some(AgentStatus::Cancelled));
1323    }
1324
1325    #[tokio::test]
1326    async fn status_ops_return_false_for_unknown_entity() {
1327        let mut world = build_world(registry_with(vec![]));
1328        assert!(!world.pause(
1329            Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1330        ));
1331        assert!(!world.resume(
1332            Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1333        ));
1334        assert!(!world.cancel(
1335            Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1336        ));
1337    }
1338
1339    #[tokio::test]
1340    async fn spawn_from_blueprint_builds_a_runnable_agent() {
1341        // End-to-end via the blueprint resolver: build → drive → complete.
1342        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1343        let e = world
1344            .spawn_from_blueprint(
1345                "agent-1".to_string(),
1346                blueprint(),
1347                "do the task",
1348                vec![crate::pipeline::ResolvedStage {
1349                    provider_name: "script".to_string(),
1350                    model: "m".to_string(),
1351                    tools: vec![],
1352                }],
1353                true,
1354            )
1355            .unwrap();
1356
1357        world.run_until_idle(20).await;
1358
1359        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1360    }
1361
1362    #[tokio::test]
1363    async fn persists_agent_snapshot_to_runs_dir() {
1364        // An agent carrying RunMetadata + TokenTotals is snapshotted to disk as it
1365        // runs; after it completes, meta.json exists with the final status.
1366        let dir = tempfile::tempdir().unwrap();
1367        let mut world = PipelineWorld::new(
1368            registry_with(vec![with_tool("c1", "do"), text("done")]),
1369            Arc::new(EchoTools),
1370            InferencePoolConfig::new(),
1371            1,
1372            dir.path().to_path_buf(),
1373            Handle::current(),
1374        );
1375        world.spawn_agent((
1376            AgentBlueprint(blueprint()),
1377            StageCursor { index: 0 },
1378            agent_state(),
1379            crate::components::MessageInbox::default(),
1380            StageProgress::default(),
1381            StageInferences(vec![stage("m")]),
1382            StageSetups(vec![setup()]),
1383            VisitCounts::default(),
1384            window(),
1385            stage("m"),
1386            setup().inference_config,
1387            crate::persistence::RunMetadata {
1388                run_id: "run-42".to_string(),
1389                agent_name: "a".to_string(),
1390                agent_path: "/p".to_string(),
1391                task: "t".to_string(),
1392                model: None,
1393                // A real directory: the tick chain fails a run whose workspace is gone.
1394                workdir: std::env::temp_dir().to_string_lossy().to_string(),
1395                num_stages: 1,
1396                started_at: 0,
1397                parent_run_id: None,
1398                metadata: std::collections::HashMap::new(),
1399                callback_url: None,
1400                callback_secret: None,
1401                title: None,
1402            },
1403            crate::persistence::TokenTotals::default(),
1404            crate::pipeline::PersistWatermark::default(),
1405            ReadyToInfer,
1406        ));
1407
1408        world.run_until_idle(20).await;
1409
1410        // The persistence worker is fire-and-forget on its own task; poll until the
1411        // final (Complete) snapshot has been flushed. A short real sleep between
1412        // polls (rather than a bare `yield_now`) gives the worker's write actual
1413        // wall-clock time to land under load - otherwise the loop can spin through
1414        // every iteration before the write completes and spuriously time out.
1415        let meta_path = dir.path().join("run-42").join("meta.json");
1416        let mut meta = None;
1417        for _ in 0..200 {
1418            if let Ok(text) = std::fs::read_to_string(&meta_path)
1419                && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1420                && m.status == leviath_core::run_meta::RunStatus::Complete
1421            {
1422                meta = Some(m);
1423                break;
1424            }
1425            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1426        }
1427
1428        let meta = meta.expect("final Complete snapshot flushed to disk");
1429        assert_eq!(meta.run_id, "run-42");
1430        assert!(dir.path().join("run-42").join("context.json").exists());
1431    }
1432
1433    #[tokio::test]
1434    async fn a_panicked_agent_is_recorded_as_errored_on_disk() {
1435        // The reported symptom in issue #109: a crashed run stayed `"running"`
1436        // in meta.json forever. `dispatch_persistence` is the *last* system in
1437        // the chain, so the tick that panics never reaches it - which is exactly
1438        // why `run_to_fixed_point` keeps driving after failing the agent.
1439        fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1440            let Some((entity, _)) = agents
1441                .iter()
1442                .find(|(_, state)| state.status == AgentStatus::Active)
1443            else {
1444                return; // the agent has been failed - nothing left to blow up
1445            };
1446            crate::tick_scope::enter(entity);
1447            panic!("exploded mid-stage");
1448        }
1449
1450        let dir = tempfile::tempdir().unwrap();
1451        let mut world = PipelineWorld::new(
1452            registry_with(vec![]),
1453            Arc::new(EchoTools),
1454            InferencePoolConfig::new(),
1455            1,
1456            dir.path().to_path_buf(),
1457            Handle::current(),
1458        );
1459        world.spawn_agent((
1460            AgentBlueprint(blueprint()),
1461            StageCursor { index: 0 },
1462            agent_state(),
1463            crate::components::MessageInbox::default(),
1464            StageProgress::default(),
1465            StageInferences(vec![stage("m")]),
1466            StageSetups(vec![setup()]),
1467            VisitCounts::default(),
1468            window(),
1469            stage("m"),
1470            setup().inference_config,
1471            crate::persistence::RunMetadata {
1472                run_id: "run-boom".to_string(),
1473                agent_name: "a".to_string(),
1474                agent_path: "/p".to_string(),
1475                task: "t".to_string(),
1476                model: None,
1477                workdir: "/w".to_string(),
1478                num_stages: 1,
1479                started_at: 0,
1480                parent_run_id: None,
1481                metadata: std::collections::HashMap::new(),
1482                callback_url: None,
1483                callback_secret: None,
1484                title: None,
1485            },
1486            crate::persistence::TokenTotals::default(),
1487            crate::pipeline::PersistWatermark::default(),
1488            ReadyToInfer,
1489        ));
1490        world.add_test_system(boom_on_active_agent);
1491        with_silent_panics(|| world.run_to_fixed_point());
1492
1493        let meta_path = dir.path().join("run-boom").join("meta.json");
1494        let mut meta = None;
1495        for _ in 0..200 {
1496            if let Ok(text) = std::fs::read_to_string(&meta_path)
1497                && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1498                && m.status == leviath_core::run_meta::RunStatus::Error
1499            {
1500                meta = Some(m);
1501                break;
1502            }
1503            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1504        }
1505        let meta = meta.expect("the panicked run must be persisted as errored");
1506        let error = meta.error.unwrap_or_default();
1507        assert!(error.contains("a pipeline system panicked"), "got: {error}");
1508        assert!(error.contains("exploded mid-stage"), "got: {error}");
1509    }
1510
1511    /// A single-stage blueprint whose stage is an `interactive_points` stage with a
1512    /// `plan_approval` point (the shape that blocks awaiting human approval).
1513    fn interactive_blueprint() -> leviath_core::Blueprint {
1514        use leviath_core::blueprint::{InteractionPoint, InteractionStyle, StageMode};
1515        let layout = leviath_core::layout::ContextLayout::new(
1516            vec![leviath_core::layout::RegionDefinition::new(
1517                "conversation".to_string(),
1518                RegionKind::Clearable,
1519                10_000,
1520            )],
1521            12_000,
1522        );
1523        let mut s = leviath_core::Stage::new(
1524            "plan".to_string(),
1525            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1526        );
1527        s.mode = StageMode::InteractivePoints {
1528            points: vec![InteractionPoint {
1529                name: "plan_approval".to_string(),
1530                prompt: "Approve?".to_string(),
1531                required: true,
1532                style: InteractionStyle::MultipleChoice,
1533                options: vec!["Approve".to_string(), "Abort".to_string()],
1534                directives: std::collections::HashMap::new(),
1535                abort_options: vec!["Abort".to_string()],
1536                edit_options: vec![],
1537                document_region: None,
1538            }],
1539        };
1540        leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
1541    }
1542
1543    #[tokio::test]
1544    async fn persists_interaction_point_when_a_live_agent_blocks() {
1545        // Drive a real agent through inference → transition → the interaction-point
1546        // lane until it blocks awaiting approval, and assert the daemon wrote the
1547        // `interactions.json` sidecar - the issue #38 persist side, end-to-end
1548        // through the live lane (a tool call first, then a text "plan", so the stage
1549        // transitions into the interaction point rather than looping on nudges).
1550        let dir = tempfile::tempdir().unwrap();
1551        let mut world = PipelineWorld::new(
1552            registry_with(vec![with_tool("c1", "read"), text("## Plan\n1. do it")]),
1553            Arc::new(EchoTools),
1554            InferencePoolConfig::new(),
1555            1,
1556            dir.path().to_path_buf(),
1557            Handle::current(),
1558        );
1559        world.insert_interaction_hub(crate::interaction_hub::InteractionHub::new());
1560        let e = world.spawn_agent((
1561            AgentBlueprint(interactive_blueprint()),
1562            StageCursor { index: 0 },
1563            agent_state(),
1564            crate::components::MessageInbox::default(),
1565            StageProgress::default(),
1566            StageInferences(vec![stage("m")]),
1567            StageSetups(vec![setup()]),
1568            VisitCounts::default(),
1569            window(),
1570            stage("m"),
1571            setup().inference_config,
1572            crate::persistence::RunMetadata {
1573                run_id: "run-ip".to_string(),
1574                agent_name: "a".to_string(),
1575                agent_path: "/p".to_string(),
1576                task: "t".to_string(),
1577                model: None,
1578                // A real directory: the tick chain fails a run whose workspace is gone.
1579                workdir: std::env::temp_dir().to_string_lossy().to_string(),
1580                num_stages: 1,
1581                started_at: 0,
1582                parent_run_id: None,
1583                metadata: std::collections::HashMap::new(),
1584                callback_url: None,
1585                callback_secret: None,
1586                title: None,
1587            },
1588            crate::persistence::TokenTotals::default(),
1589            crate::pipeline::PersistWatermark::default(),
1590            ReadyToInfer,
1591        ));
1592
1593        world.run_until_idle(30).await;
1594        // `run_until_idle` stops once no inference/tool is in flight, but the
1595        // interaction-point ask task registers in the hub just after; the real
1596        // daemon's `run()` loop catches its wake, so pump fixed points here until
1597        // `reflect_interaction_status` flips the agent to Waiting (and persistence
1598        // captures the sidecar).
1599        for _ in 0..50 {
1600            if world.agent_status(e) == Some(AgentStatus::Waiting) {
1601                break;
1602            }
1603            tokio::task::yield_now().await;
1604            world.run_to_fixed_point();
1605        }
1606        assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
1607
1608        // Poll until the interaction sidecar lands (the persistence worker writes it
1609        // on its own task once the agent is parked Waiting at the point).
1610        let path = dir.path().join("run-ip").join("interactions.json");
1611        let mut sidecar = None;
1612        for _ in 0..200 {
1613            if let Ok(t) = std::fs::read_to_string(&path)
1614                && let Ok(s) =
1615                    serde_json::from_str::<crate::interaction_points::InteractionPointState>(&t)
1616            {
1617                sidecar = Some(s);
1618                break;
1619            }
1620            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1621        }
1622        let s = sidecar.expect("interaction-point sidecar flushed to disk");
1623        assert_eq!(s.cursor, 0);
1624        assert_eq!(s.round, 0);
1625        assert_eq!(s.body, "## Plan\n1. do it");
1626    }
1627
1628    #[tokio::test]
1629    async fn flush_and_stop_drains_queued_snapshots() {
1630        // Unlike a plain shutdown, `flush_and_stop` awaits the persistence worker,
1631        // so the final snapshot is guaranteed on disk the instant it returns - no
1632        // filesystem polling required (contrast the test above).
1633        let dir = tempfile::tempdir().unwrap();
1634        let mut world = PipelineWorld::new(
1635            registry_with(vec![with_tool("c1", "do"), text("done")]),
1636            Arc::new(EchoTools),
1637            InferencePoolConfig::new(),
1638            1,
1639            dir.path().to_path_buf(),
1640            Handle::current(),
1641        );
1642        world.spawn_agent((
1643            AgentBlueprint(blueprint()),
1644            StageCursor { index: 0 },
1645            agent_state(),
1646            crate::components::MessageInbox::default(),
1647            StageProgress::default(),
1648            StageInferences(vec![stage("m")]),
1649            StageSetups(vec![setup()]),
1650            VisitCounts::default(),
1651            window(),
1652            stage("m"),
1653            setup().inference_config,
1654            crate::persistence::RunMetadata {
1655                run_id: "run-flush".to_string(),
1656                agent_name: "a".to_string(),
1657                agent_path: "/p".to_string(),
1658                task: "t".to_string(),
1659                model: None,
1660                // A real directory: the tick chain fails a run whose workspace is gone.
1661                workdir: std::env::temp_dir().to_string_lossy().to_string(),
1662                num_stages: 1,
1663                started_at: 0,
1664                parent_run_id: None,
1665                metadata: std::collections::HashMap::new(),
1666                callback_url: None,
1667                callback_secret: None,
1668                title: None,
1669            },
1670            crate::persistence::TokenTotals::default(),
1671            crate::pipeline::PersistWatermark::default(),
1672            ReadyToInfer,
1673        ));
1674
1675        world.run_until_idle(20).await;
1676        world.flush_and_stop().await;
1677
1678        // Read immediately - the drain guarantees the write landed.
1679        let meta_path = dir.path().join("run-flush").join("meta.json");
1680        let text = std::fs::read_to_string(&meta_path).expect("meta.json flushed on stop");
1681        let meta: leviath_core::run_meta::RunMeta = serde_json::from_str(&text).unwrap();
1682        assert_eq!(meta.run_id, "run-flush");
1683        assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Complete);
1684
1685        // A second call is a no-op (resource already removed, task taken) - no panic.
1686        world.flush_and_stop().await;
1687        assert!(meta_path.exists());
1688    }
1689
1690    #[tokio::test]
1691    async fn world_init_and_restore_needs_no_daemon_infra() {
1692        // `PipelineWorld::new` + `restore::restore_agent` form a self-contained
1693        // spin-up→restore path: no control socket, HTTP server, PID files, or build
1694        // markers - only providers, a tool service, a runs dir, and a runtime. This
1695        // locks that in so the daemon wiring stays optional.
1696        use leviath_core::region::EntryKind;
1697        use leviath_core::run_meta::{ContextSnapshot, RegionEntrySnapshot, RegionSnapshot};
1698
1699        let dir = tempfile::tempdir().unwrap();
1700        let mut world = PipelineWorld::new(
1701            registry_with(vec![text("unused")]),
1702            Arc::new(EchoTools),
1703            InferencePoolConfig::new(),
1704            1,
1705            dir.path().to_path_buf(),
1706            Handle::current(),
1707        );
1708        let entity = world.spawn_agent((
1709            AgentBlueprint(blueprint()),
1710            StageCursor { index: 0 },
1711            agent_state(),
1712            crate::components::MessageInbox::default(),
1713            StageProgress::default(),
1714            StageInferences(vec![stage("m")]),
1715            StageSetups(vec![setup()]),
1716            VisitCounts::default(),
1717            window(),
1718            stage("m"),
1719            setup().inference_config,
1720            crate::persistence::TokenTotals::default(),
1721        ));
1722
1723        let snapshot = ContextSnapshot {
1724            stage_name: "s0".to_string(),
1725            total_tokens: 4,
1726            max_tokens: 10_000,
1727            regions: vec![RegionSnapshot {
1728                name: "conversation".to_string(),
1729                kind: "clearable".to_string(),
1730                current_tokens: 4,
1731                max_tokens: 10_000,
1732                entries: vec![RegionEntrySnapshot {
1733                    content: "restored turn".to_string(),
1734                    tokens: 4,
1735                    kind: EntryKind::UserMessage,
1736                    metadata: None,
1737                    key: None,
1738                    taint: Default::default(),
1739                }],
1740            }],
1741        };
1742        crate::restore::restore_agent(
1743            world.world_mut(),
1744            entity,
1745            &snapshot,
1746            0,
1747            3,
1748            crate::persistence::TokenTotals::default(),
1749        );
1750
1751        let state = world
1752            .world()
1753            .get::<crate::components::AgentState>(entity)
1754            .unwrap();
1755        assert_eq!(state.status, AgentStatus::Active);
1756        assert_eq!(state.iteration, 3);
1757        let win = world
1758            .world()
1759            .get::<crate::components::ContextWindow>(entity)
1760            .unwrap();
1761        assert_eq!(
1762            win.get_region("conversation").unwrap().content[0].content,
1763            "restored turn"
1764        );
1765    }
1766
1767    #[tokio::test]
1768    async fn spawn_from_blueprint_errors_on_oversized_system_prompt() {
1769        let mut world = build_world(registry_with(vec![]));
1770        // A blueprint whose stage carries an enormous system prompt in a tiny
1771        // pinned region overflows at spawn.
1772        let layout = leviath_core::layout::ContextLayout::new(
1773            vec![leviath_core::layout::RegionDefinition::new(
1774                "task".to_string(),
1775                RegionKind::Pinned,
1776                50,
1777            )],
1778            1000,
1779        );
1780        let mut s = leviath_core::Stage::new(
1781            "s".to_string(),
1782            leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1783        );
1784        s.config.insert(
1785            "system_prompt".to_string(),
1786            serde_json::Value::String("x".repeat(100_000)),
1787        );
1788        let bp = leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout);
1789
1790        let err = world.spawn_from_blueprint(
1791            "a".to_string(),
1792            bp,
1793            "task",
1794            vec![crate::pipeline::ResolvedStage {
1795                provider_name: "script".to_string(),
1796                model: "m".to_string(),
1797                tools: vec![],
1798            }],
1799            true,
1800        );
1801        assert!(err.is_err());
1802    }
1803
1804    #[tokio::test]
1805    async fn wake_handle_and_run_until_idle_bound_are_exposed() {
1806        // Exercises the wake handle accessor and the max-waits safety bound on a
1807        // world with an agent parked on an in-flight inference that never
1808        // resolves within the bound (script returns after we stop waiting).
1809        let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1810        let _ = world.wake_handle();
1811        let e = spawn(&mut world);
1812        world.run_until_idle(0).await; // bound 0 ⇒ no extra waits
1813        // With no waits allowed we may not have observed completion yet; drain.
1814        world.run_until_idle(20).await;
1815        assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1816    }
1817}