leviath_runtime/host/mod.rs
1//! The world host: the daemon-side wrapper that owns a single [`PipelineWorld`],
2//! maps stable **run ids** to ECS entities, and interleaves external **control
3//! operations** with driving the world - all on one task, so there is never any
4//! locking around the world.
5//!
6//! Clients (a control socket, the TUI, the CLI) don't hold entities - those are
7//! generational indices meaningful only inside the world. They address agents by
8//! run id. The host keeps the `run_id → Entity` map and turns each
9//! [`ControlOp`] into the corresponding [`PipelineWorld`] call, replying on the
10//! op's oneshot channel.
11//!
12//! The serve loop drives the world to quiescence, then parks until either an
13//! async result wakes it, a control op arrives, or shutdown is signalled -
14//! handling a control op and then re-driving to quiescence so its effect (a
15//! resume, a delivered message) is applied immediately.
16
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::time::Duration;
19
20use bevy_ecs::entity::Entity;
21use tokio::sync::broadcast;
22use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
23
24use crate::components::{
25 AgentMessage, AgentState, AgentStatus, AwaitingInteraction, ContextWindow, ParentRef,
26 SubAgentChildren, WaitReason,
27};
28use crate::interaction_hub::InteractionHub;
29use crate::persistence::{RunMetadata, TokenTotals};
30use crate::world::{AgentId, LaneSnapshot, PipelineWorld};
31
32// Sections of the former single-file host, one per concern. Glob re-exported so
33// every existing `host::ControlOp` / `host::WorldEvent` path keeps working and
34// the split stays a pure move.
35mod events;
36pub use events::*;
37mod types;
38pub use types::*;
39
40/// Owns the world and the run-id map; drives the world and services control ops.
41pub struct WorldHost {
42 world: PipelineWorld,
43 by_run_id: HashMap<String, AgentId>,
44 interactions: InteractionHub,
45 spawner: Option<Spawner>,
46 spawn_preprocessor: Option<SpawnPreprocessor>,
47 reloader: Option<Reloader>,
48 force_terminator: Option<ForceTerminator>,
49 reaper: Option<Reaper>,
50 events: broadcast::Sender<WorldEvent>,
51 emitted: HashMap<String, Emitted>,
52 emitted_interactions: HashSet<String>,
53 /// Sub-agent world-access requests from tool lanes. The host holds a `tx`
54 /// clone so the receiver never closes (its `recv` never yields `None`).
55 subagent_tx: UnboundedSender<SubAgentOp>,
56 subagent_rx: UnboundedReceiver<SubAgentOp>,
57 /// How often [`Self::serve`] re-drives the world even though nothing woke
58 /// it. See [`Self::set_redrive_interval`].
59 redrive: Duration,
60 /// Consecutive re-drives that found the lanes full and nothing moved. See
61 /// [`Self::observe_redrive`].
62 dead_cycles: u32,
63 /// The progress fingerprint as of the previous re-drive, or `None` before
64 /// the first one.
65 last_progress: Option<u64>,
66 /// Extra tool-lane permits the relief valve has handed out and not yet
67 /// reclaimed (see [`Self::decay_relief_if_healthy`]).
68 relief_granted: usize,
69 /// Consecutive re-drives that found the lane healthy (no dead cycles, no
70 /// queue) while relief was outstanding - the decay countdown.
71 healthy_cycles: u32,
72 /// Dead cycles the daemon tolerates before widening the tool lane. `0`
73 /// disables relief. See [`Self::set_dead_cycles_before_relief`].
74 dead_cycles_before_relief: u32,
75 /// Runs unloaded recently enough to still be worth reporting, oldest first,
76 /// each paired with the unix second it was unloaded. See
77 /// [`Self::record_finished`].
78 finished: VecDeque<(i64, RunListEntry)>,
79 /// How long an unloaded run stays in [`Self::finished`]. `0` keeps none.
80 /// See [`Self::set_finished_retention_secs`].
81 finished_retention_secs: u64,
82 /// Paused runs the host has paged out of the world, by run id, each holding
83 /// its last listing row. A parked run's full state is on disk; `Resume`,
84 /// `Message` and `Cancel` all page it back through
85 /// [`Self::resolve_or_reload`], and [`Self::list`] keeps reporting it so an
86 /// operator's `lev ps` view does not change just because the daemon stopped
87 /// spending memory on a run nobody is driving.
88 parked: HashMap<String, RunListEntry>,
89}
90
91/// Consecutive healthy re-drives (no dead cycles, empty tool queue) before the
92/// relief-decay valve reclaims one granted permit. Each re-drive is seconds
93/// apart, so four of them is a comfortably-over margin - and each further
94/// healthy cycle reclaims one more, so a full lane's worth drains in minutes.
95const HEALTHY_CYCLES_BEFORE_DECAY: u32 = 4;
96
97/// How often the serve loop re-drives the world on its own.
98///
99/// The loop is event-driven, so a missed wake anywhere parks it indefinitely -
100/// the daemon looks alive while nothing progresses, which is what issue #189
101/// reported as hours of frozen agents. This bounds any such wedge to one
102/// interval instead of "until something unrelated happens", and gives the lane
103/// heartbeat a place to run.
104///
105/// Deliberately not configurable: it is a correctness backstop, not a tuning
106/// knob. A no-op re-drive is one tick over a handful of systems plus an event
107/// diff, so at this cadence it costs nothing measurable.
108const DEFAULT_REDRIVE_INTERVAL: Duration = Duration::from_secs(30);
109
110/// How many consecutive dead cycles trigger the tool-lane relief valve.
111///
112/// At the 30-second re-drive that is five minutes of a full lane going nowhere -
113/// long enough that ordinary backpressure never reaches it, short enough that a
114/// genuinely wedged daemon is not left overnight. Served from
115/// `[limits] dead_cycles_before_relief`; `0` disables relief.
116pub const DEFAULT_DEAD_CYCLES_BEFORE_RELIEF: u32 = 10;
117
118/// How long a run stays in the listing after the daemon unloads it.
119///
120/// A terminal agent is unloaded a pass or two after it finishes, and until now
121/// it vanished from the listing at that moment. A run that died on its first
122/// inference was therefore indistinguishable from one that had never been
123/// spawned, which is what left the scheduler in issue #205 with nothing to go on
124/// but a stopwatch: it could not tell a dead spawn from a slow one, so it
125/// reverted the work and spawned again, for forty minutes.
126///
127/// Five minutes covers several polls of any scheduler that checks in about once
128/// a minute, so a single missed or slow poll does not lose the evidence. It is
129/// also what the rest of the daemon already means by "long enough that a hiccup
130/// cannot cause it": the dashboard calls a run stale at 300 seconds, and
131/// [`DEFAULT_DEAD_CYCLES_BEFORE_RELIEF`] at the 30-second re-drive works out to
132/// the same five minutes.
133///
134/// Served from `[limits] finished_retention_secs`; `0` keeps nothing and
135/// restores the old behaviour.
136pub const DEFAULT_FINISHED_RETENTION_SECS: u64 = 300;
137
138/// How many unloaded runs [`WorldHost::finished`] holds before the oldest are
139/// dropped, whatever the retention window says.
140///
141/// Not configurable: it is a memory bound, not a tuning knob. A factory that
142/// finishes runs faster than this fills the window keeps the most recent ones,
143/// which are the ones anyone is still asking about. Set the window shorter to
144/// control how much the listing shows; this only stops it growing without end.
145const MAX_RETAINED_FINISHED: usize = 256;
146
147impl WorldHost {
148 /// Wrap a world with a fresh interaction hub.
149 pub fn new(world: PipelineWorld) -> Self {
150 Self::with_interactions(world, InteractionHub::new())
151 }
152
153 /// Wrap a world with a specific interaction hub - the daemon shares one hub
154 /// between the tool service's per-agent backends and this host.
155 pub fn with_interactions(mut world: PipelineWorld, interactions: InteractionHub) -> Self {
156 // 256, not more: a tokio broadcast ring never shrinks, so every slot a
157 // busy period fills stays allocated (holding its event's strings) for
158 // the daemon's life. Consumers here are live relays, not replayers -
159 // one that falls a full ring behind gets a Lagged skip either way.
160 let (events, _) = broadcast::channel(256);
161 // Let ECS systems (the persistence drain) push events - per-agent log
162 // lines - into the same stream the control transport serves.
163 world
164 .world_mut()
165 .insert_resource(WorldEventSink(events.clone()));
166 let (subagent_tx, subagent_rx) = tokio::sync::mpsc::unbounded_channel();
167 Self {
168 world,
169 by_run_id: HashMap::new(),
170 interactions,
171 spawner: None,
172 spawn_preprocessor: None,
173 reloader: None,
174 force_terminator: None,
175 reaper: None,
176 events,
177 emitted: HashMap::new(),
178 emitted_interactions: HashSet::new(),
179 parked: HashMap::new(),
180 subagent_tx,
181 subagent_rx,
182 redrive: DEFAULT_REDRIVE_INTERVAL,
183 dead_cycles: 0,
184 last_progress: None,
185 relief_granted: 0,
186 healthy_cycles: 0,
187 dead_cycles_before_relief: DEFAULT_DEAD_CYCLES_BEFORE_RELIEF,
188 finished: VecDeque::new(),
189 finished_retention_secs: DEFAULT_FINISHED_RETENTION_SECS,
190 }
191 }
192}
193
194// Sections of the former single-file host impl, one per concern. An inherent
195// impl may live in any module of the defining crate, so each file below carries
196// its own `impl WorldHost` block rather than a trait or a free function.
197mod emit;
198mod health;
199mod listing;
200mod subagents;
201
202impl WorldHost {
203 /// Register any agent that exists in the world but is missing from the run-id
204 /// map, so the host's view is the world's view.
205 ///
206 /// Not every agent arrives through a `Spawn` control op: fan-out workers are
207 /// built straight into the world by the fan-out spawner, which has no handle
208 /// on the host to register them. An unregistered agent is invisible to `list`
209 /// (so `lev ps` never showed a worker), never reaped (its sandbox and tool
210 /// state leak), and - worst - un-cancellable, because a cancel by its run id
211 /// misses the map, falls through to the reloader, and pages a **second** live
212 /// entity in from that run's on-disk state while the original keeps running.
213 /// Adopting them here is idempotent and keeps a stale mapping from winning:
214 /// a registered id whose entity has been despawned is re-pointed.
215 fn adopt_unregistered_runs(&mut self) {
216 let live: Vec<(String, Entity)> = self
217 .world
218 .world_mut()
219 .query::<(Entity, &RunMetadata)>()
220 .iter(self.world.world())
221 .map(|(entity, md)| (md.run_id.clone(), entity))
222 .collect();
223 for (run_id, entity) in live {
224 // Straight out of this world's query, so it is ours by construction.
225 let agent = self.world.own_agent(entity);
226 if self.live_entity(&run_id) != Some(agent) {
227 self.by_run_id.insert(run_id, agent);
228 }
229 }
230 }
231
232 /// Whether a paused agent is safe to page out of the world.
233 ///
234 /// Conservative on purpose - this is the restart-equivalence question, and
235 /// only shapes where the answer is a settled "yes" qualify:
236 /// - status is `Paused`, and the *persisted* status is too (the watermark
237 /// proves the paused snapshot was dispatched, so disk can rebuild it);
238 /// - it is a standalone root: no parent that might address it by entity,
239 /// no children whose links a page-in would have to rebuild;
240 /// - no open interaction and no fan-out in flight (a pause that landed
241 /// mid-prompt or mid-split keeps its live machinery).
242 fn parkable(&self, entity: Entity, status: &AgentStatus) -> bool {
243 if !matches!(status, AgentStatus::Paused) {
244 return false;
245 }
246 // No reloader, no parking: a host that cannot page a run back in
247 // (an embedded world, a bare test host) must keep it resident, or
248 // "paused" silently becomes "gone".
249 if self.reloader.is_none() {
250 return false;
251 }
252 let world = self.world.world();
253 let paused_persisted = world
254 .get::<crate::pipeline::PersistWatermark>(entity)
255 .and_then(|w| w.persisted_status())
256 == Some(leviath_core::run_meta::RunStatus::Paused);
257 paused_persisted
258 && world.get::<crate::components::ParentRef>(entity).is_none()
259 && world.get::<SubAgentChildren>(entity).is_none()
260 && world.get::<crate::fanout::FanOutWaiting>(entity).is_none()
261 && world
262 .get::<crate::interaction_points::AwaitingInteractionPoint>(entity)
263 .is_none()
264 && world.get::<AwaitingInteraction>(entity).is_none()
265 }
266
267 /// Whether a terminal agent is safe to unload: it has no **live** parent that
268 /// might still be waiting on it. True for a root (no `ParentRef`), or when its
269 /// parent has been despawned or is itself terminal; false while a non-terminal
270 /// parent could still be gating on this child.
271 fn no_live_parent(&self, entity: Entity) -> bool {
272 let world = self.world.world();
273 match world.get::<crate::components::ParentRef>(entity) {
274 None => true,
275 Some(parent_ref) => match world.get::<AgentState>(parent_ref.parent_entity) {
276 None => true,
277 Some(state) => matches!(
278 state.status,
279 AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
280 ),
281 },
282 }
283 }
284
285 /// Install the spawner used to service `Spawn` control ops. Without one, a
286 /// `Spawn` op replies with an error.
287 pub fn set_spawner(&mut self, spawner: Spawner) {
288 self.spawner = Some(spawner);
289 }
290
291 /// Install the async hook awaited before each top-level `Spawn` (see
292 /// [`SpawnPreprocessor`]).
293 pub fn set_spawn_preprocessor(&mut self, pp: SpawnPreprocessor) {
294 self.spawn_preprocessor = Some(pp);
295 }
296
297 /// Install the reloader used to page an unloaded run back in on demand.
298 /// Without one, an op targeting a run that isn't in memory just misses.
299 pub fn set_reloader(&mut self, reloader: Reloader) {
300 self.reloader = Some(reloader);
301 }
302
303 /// Install the [`ForceTerminator`] used to terminate a run on disk when the
304 /// world cannot hold it. Without one, a cancel that misses in the world and
305 /// can't be reloaded just misses.
306 pub fn set_force_terminator(&mut self, force_terminator: ForceTerminator) {
307 self.force_terminator = Some(force_terminator);
308 }
309
310 /// Install the reap hook run just before each terminal agent is despawned,
311 /// so the daemon can tear down that agent's sandbox and drop its tool state.
312 /// Without one, reaping just despawns the entity (the prior behavior).
313 pub fn set_reaper(&mut self, reaper: Reaper) {
314 self.reaper = Some(reaper);
315 }
316
317 /// Resolve a run id to a live entity, paging it in from disk if it has been
318 /// unloaded (and a reloader is installed). Returns `None` if the run is
319 /// neither live nor resumable from disk. Newly-reloaded runs are registered.
320 fn resolve_or_reload(&mut self, run_id: &str) -> Option<AgentId> {
321 if let Some(entity) = self.live_entity(run_id) {
322 return Some(entity);
323 }
324 let entity = (self.reloader.as_mut()?)(&mut self.world, run_id)?;
325 self.by_run_id.insert(run_id.to_string(), entity);
326 // Live again: its listing row comes off the entity, not the parked map.
327 self.parked.remove(run_id);
328 Some(entity)
329 }
330
331 /// A clone of the interaction hub, for building per-agent backends.
332 pub fn interactions(&self) -> InteractionHub {
333 self.interactions.clone()
334 }
335
336 /// Mutable access to the underlying world (for the spawner to add agents).
337 pub fn world_mut(&mut self) -> &mut PipelineWorld {
338 &mut self.world
339 }
340
341 /// Record the run-id → entity mapping for a freshly-spawned agent.
342 pub fn register(&mut self, run_id: impl Into<String>, agent: AgentId) {
343 let run_id = run_id.into();
344 self.parked.remove(&run_id);
345 self.by_run_id.insert(run_id, agent);
346 }
347
348 /// Resolve a run id to a **live** entity (one that still exists in the world).
349 fn live_entity(&self, run_id: &str) -> Option<AgentId> {
350 let agent = *self.by_run_id.get(run_id)?;
351 self.world
352 .world()
353 .get::<AgentState>(agent.entity())
354 .map(|_| agent)
355 }
356
357 /// Apply one control op and reply on its channel. A dropped reply receiver is
358 /// harmless (the requester went away).
359 pub fn handle(&mut self, op: ControlOp) {
360 match op {
361 ControlOp::Spawn { args, reply } => {
362 let result = match self.spawner.as_mut() {
363 // Spawning runs outside the pipeline schedule, so it isn't
364 // covered by `run_isolated`'s panic guard: a panic while
365 // parsing a blueprint or building a sandbox would otherwise
366 // unwind the whole serve task and take the daemon with it.
367 // As with `run_isolated`, the world may be left holding a
368 // partially-built entity - the run just never registers.
369 Some(spawner) => {
370 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
371 spawner(&mut self.world, &args)
372 })) {
373 Ok(Ok(entity)) => {
374 // Spawned into this world, so it is ours.
375 let agent = self.world.own_agent(entity);
376 self.by_run_id.insert(args.run_id.clone(), agent);
377 Ok(args.run_id.clone())
378 }
379 Ok(Err(e)) => Err(e),
380 Err(_) => Err("agent spawn panicked".to_string()),
381 }
382 }
383 None => Err("this daemon cannot spawn agents".to_string()),
384 };
385 // A failed spawn must leave a trace daemon-side: the error goes
386 // back over the socket to a client that may have already exited,
387 // and nothing is written to disk, so without this log line the
388 // failure is invisible (issue #107).
389 if let Err(error) = &result {
390 tracing::error!(
391 run_id = %args.run_id,
392 blueprint = %args.blueprint_path,
393 workdir = %args.workdir,
394 error = %error,
395 "agent spawn failed"
396 );
397 }
398 let _ = reply.send(result);
399 }
400 ControlOp::Result { run_id, reply } => {
401 // Live entities only. An unloaded run's answer is on disk in
402 // `meta.json`, which is what `lev result` reads; keeping a copy
403 // of every finished run's answer in memory would defeat the
404 // point of bounding the finished buffer.
405 let output = self
406 .live_entity(&run_id)
407 .and_then(|agent| {
408 self.world
409 .world()
410 .get::<crate::persistence::FinalOutput>(agent.entity())
411 })
412 .map(|o| o.0.clone());
413 let _ = reply.send(output);
414 }
415 ControlOp::Status { run_id, reply } => {
416 // A run the daemon has unloaded still has an answer for a
417 // while, so a caller that asks a moment too late learns how the
418 // run ended instead of being told there is no such run.
419 let status = self
420 .live_entity(&run_id)
421 .and_then(|e| self.world.agent_status(e))
422 .or_else(|| self.parked.get(&run_id).map(|e| e.status.clone()))
423 .or_else(|| {
424 self.finished
425 .iter()
426 .find(|(_, e)| e.run_id == run_id)
427 .map(|(_, e)| e.status.clone())
428 });
429 let _ = reply.send(status);
430 }
431 ControlOp::Pause { run_id, reply } => {
432 let ok = self
433 .resolve_or_reload(&run_id)
434 .is_some_and(|e| self.world.pause(e));
435 let _ = reply.send(ok);
436 }
437 ControlOp::Resume { run_id, reply } => {
438 let ok = self
439 .resolve_or_reload(&run_id)
440 .is_some_and(|e| self.world.resume(e));
441 let _ = reply.send(ok);
442 }
443 ControlOp::Cancel { run_id, reply } => {
444 // Cancel is unconditional: it either takes effect in the world
445 // (root plus every descendant) or, when the run can't be held
446 // there at all, is forced onto its on-disk state. It reports
447 // `false` only when there is genuinely no such run anywhere -
448 // otherwise a run whose blueprint had moved stayed `running` on
449 // disk forever with no way to get rid of it.
450 let ok = self.cancel_tree(&run_id)
451 || self
452 .force_terminator
453 .as_mut()
454 .is_some_and(|terminate| terminate(&run_id));
455 let _ = reply.send(ok);
456 }
457 ControlOp::List { reply } => {
458 let _ = reply.send(RunListing {
459 runs: self.list(),
460 finished: self.finished(),
461 health: self.health(),
462 });
463 }
464 ControlOp::Message {
465 agent_id,
466 content,
467 target_region,
468 reply,
469 } => {
470 // Page the target in if it was unloaded, so delivery finds it.
471 self.resolve_or_reload(&agent_id);
472 let ok = self
473 .world
474 .send_message(AgentMessage {
475 agent_id,
476 content,
477 target_region,
478 })
479 .is_ok();
480 let _ = reply.send(ok);
481 }
482 ControlOp::ListInteractions { reply } => {
483 let _ = reply.send(self.interactions.pending());
484 }
485 ControlOp::AnswerInteraction { response, reply } => {
486 let _ = reply.send(self.interactions.answer(response));
487 }
488 ControlOp::CancelInteraction { request_id, reply } => {
489 let _ = reply.send(self.interactions.cancel(&request_id));
490 }
491 ControlOp::Shutdown { reply } => {
492 // Reply first (best effort), then trigger the world's shutdown so
493 // the serve loop's next `select!` returns.
494 let _ = reply.send(true);
495 self.world.shutdown();
496 }
497 }
498 }
499
500 /// Flush all queued persistence and stop the hosted world, guaranteeing every
501 /// dirty agent's final snapshot reaches disk (see
502 /// [`PipelineWorld::flush_and_stop`]). Invoked automatically when [`Self::serve`]
503 /// returns; also exposed directly for callers that drive the world themselves.
504 pub async fn flush_and_stop(&mut self) {
505 self.world.flush_and_stop().await;
506 }
507
508 /// Run the host: drive the world to quiescence, then park until an async
509 /// result wakes it, a control op arrives, or shutdown is signalled. Returns
510 /// when shutdown fires or the control channel closes - and before returning,
511 /// **flushes all queued persistence to disk** ([`Self::flush_and_stop`]) so a
512 /// clean daemon shutdown never loses a dirty agent's final snapshot.
513 pub async fn serve(&mut self, mut control_rx: UnboundedReceiver<ControlOp>) {
514 let wake = self.world.wake_handle();
515 let shutdown = self.world.shutdown_handle();
516 // `interval_at` rather than `interval`: the latter's first tick is
517 // immediately ready, which would spin one pointless pass at startup.
518 // `Delay` keeps a slow drive from queueing a burst of catch-up ticks.
519 let mut redrive =
520 tokio::time::interval_at(tokio::time::Instant::now() + self.redrive, self.redrive);
521 redrive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
522 'serve: loop {
523 self.world.run_to_fixed_point();
524 self.emit_events();
525 tokio::select! {
526 _ = wake.notified() => {}
527 _ = shutdown.notified() => break 'serve,
528 // The backstop. Everything else here is edge-triggered, so a
529 // release or completion that forgets to wake us would otherwise
530 // park the daemon indefinitely with work left to do. Re-driving
531 // on a timer bounds that to one interval, and is where the lane
532 // heartbeat reports what the loop is actually waiting on.
533 _ = redrive.tick() => self.observe_redrive(),
534 op = control_rx.recv() => {
535 match op {
536 // Await the spawn preprocessor (e.g. lazy MCP connect) before
537 // the sync spawner runs, so the pool is warm. The returned
538 // future is `'static`, so no borrow of `self`/`op` outlives it.
539 Some(op) => {
540 let pre = match &op {
541 ControlOp::Spawn { args, .. } => {
542 self.spawn_preprocessor.as_ref().map(|pp| pp(args))
543 }
544 _ => None,
545 };
546 if let Some(fut) = pre {
547 fut.await;
548 }
549 self.handle(op);
550 }
551 None => break 'serve, // all control senders dropped
552 }
553 }
554 // The host holds a `subagent_tx`, so this only yields `Some`.
555 Some(sub) = self.subagent_rx.recv() => {
556 // Warm a spawning sub-agent's MCP servers first, same as a
557 // top-level Spawn (both run in this async loop).
558 let pre = match &sub {
559 SubAgentOp::Spawn { args, .. } => {
560 self.spawn_preprocessor.as_ref().map(|pp| pp(args))
561 }
562 _ => None,
563 };
564 if let Some(fut) = pre {
565 fut.await;
566 }
567 self.handle_subagent(sub);
568 }
569 }
570 }
571 // Shutting down: drain the persistence lane before the world is dropped.
572 self.flush_and_stop().await;
573 }
574}
575
576#[cfg(test)]
577#[path = "../host_tests.rs"]
578mod tests;