Skip to main content

leviath_runtime/host/
emit.rs

1//! Turning world state into the event stream subscribers see.
2//!
3//! The change-detection pass: compare every run against the snapshot kept from
4//! the previous cycle and emit only what actually moved. This is why an idle
5//! daemon produces no events rather than a heartbeat of unchanged status.
6
7use super::*;
8
9impl WorldHost {
10    /// Subscribe to [`WorldEvent`]s. The HTTP/WS gateway uses this (via the
11    /// control transport's `Subscribe`) to push updates instead of polling.
12    pub fn subscribe(&self) -> broadcast::Receiver<WorldEvent> {
13        self.events.subscribe()
14    }
15
16    /// The world-event sender, handed to the control transport so a `Subscribe`
17    /// connection can stream events.
18    pub fn event_sender(&self) -> broadcast::Sender<WorldEvent> {
19        self.events.clone()
20    }
21
22    /// Diff every registered run against its last-emitted snapshot and broadcast
23    /// what changed (status/tokens/context/completion) plus any new interaction.
24    /// Called after each drive to quiescence, so subscribers see every change.
25    pub(super) fn emit_events(&mut self) {
26        self.adopt_unregistered_runs();
27        let pairs: Vec<(String, AgentId)> = self
28            .by_run_id
29            .iter()
30            .map(|(k, &v)| (k.clone(), v))
31            .collect();
32        // Terminal agents to unload from memory this pass (their disk state is
33        // preserved and still viewable). Collected during the loop, reaped after.
34        // The listing row travels with each one: it is built here, while the
35        // entity is untouched, rather than in the reap loop below, where the
36        // daemon's reap hook has already had the world and is free to have taken
37        // the components it reads.
38        let mut to_reap: Vec<(String, Entity, RunListEntry)> = Vec::new();
39        let mut to_park: Vec<(String, Entity, RunListEntry)> = Vec::new();
40        let now = chrono::Utc::now().timestamp();
41        for (run_id, agent) in pairs {
42            // Unwrapped once: everything below reaches into this world's ECS,
43            // where same-world is true by construction.
44            let entity = agent.entity();
45            let Some(state) = self.world.world().get::<AgentState>(entity) else {
46                continue; // reaped between registration and now
47            };
48            let agent_id = state.agent_id.clone();
49            let status = status_str(&state.status);
50            let terminal = matches!(
51                state.status,
52                AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
53            );
54            let cur = {
55                let totals = self
56                    .world
57                    .world()
58                    .get::<TokenTotals>(entity)
59                    .copied()
60                    .unwrap_or_default();
61                let (context_tokens, _) = self
62                    .world
63                    .world()
64                    .get::<ContextWindow>(entity)
65                    .map(|w| (w.current_tokens, w.max_tokens))
66                    .unwrap_or((0, 0));
67                Emitted {
68                    status,
69                    stage: state.current_stage.clone(),
70                    iteration: state.iteration,
71                    tool_calls: totals.tool_calls,
72                    accepts_messages: state.accepts_messages,
73                    prompt_tokens: totals.prompt_tokens,
74                    completion_tokens: totals.completion_tokens,
75                    cached_tokens: totals.cached_tokens,
76                    cache_write_tokens: totals.cache_write_tokens,
77                    context_tokens,
78                    terminal,
79                    wait_reason: self.wait_reason(agent),
80                }
81            };
82            let max_tokens = self
83                .world
84                .world()
85                .get::<ContextWindow>(entity)
86                .map(|w| w.max_tokens)
87                .unwrap_or(0);
88            let prev = self.emitted.get(&run_id).cloned();
89
90            if prev.is_none() {
91                let blueprint = self
92                    .world
93                    .world()
94                    .get::<RunMetadata>(entity)
95                    .map(|m| m.agent_name.clone())
96                    .unwrap_or_default();
97                let _ = self.events.send(WorldEvent::Spawned {
98                    run_id: run_id.clone(),
99                    agent_id: agent_id.clone(),
100                    blueprint,
101                });
102            }
103
104            let status_key = |e: &Emitted| {
105                (
106                    e.status,
107                    e.stage.clone(),
108                    e.iteration,
109                    e.tool_calls,
110                    e.accepts_messages,
111                    e.wait_reason.clone(),
112                )
113            };
114            // The reason is part of the key, so a parent whose worker count
115            // falls sends an event: that is progress, and a subscriber that
116            // only heard "waiting" once would show a stale count for the rest
117            // of the fan-out. Bounded by the number of workers.
118            if prev.as_ref().map(status_key) != Some(status_key(&cur)) {
119                let _ = self.events.send(WorldEvent::Status {
120                    run_id: run_id.clone(),
121                    agent_id: agent_id.clone(),
122                    status: status.to_string(),
123                    stage: cur.stage.clone(),
124                    iteration: cur.iteration,
125                    tool_calls: cur.tool_calls,
126                    accepts_messages: cur.accepts_messages,
127                    wait_reason: cur.wait_reason.clone(),
128                });
129            }
130
131            let token_key = |e: &Emitted| {
132                (
133                    e.prompt_tokens,
134                    e.completion_tokens,
135                    e.cached_tokens,
136                    e.cache_write_tokens,
137                )
138            };
139            if prev.as_ref().map(token_key) != Some(token_key(&cur)) {
140                let _ = self.events.send(WorldEvent::Tokens {
141                    run_id: run_id.clone(),
142                    agent_id: agent_id.clone(),
143                    prompt_tokens: cur.prompt_tokens,
144                    completion_tokens: cur.completion_tokens,
145                    cached_tokens: cur.cached_tokens,
146                    cache_write_tokens: cur.cache_write_tokens,
147                });
148            }
149
150            if prev.as_ref().map(|e| e.context_tokens) != Some(cur.context_tokens) {
151                let _ = self.events.send(WorldEvent::Context {
152                    run_id: run_id.clone(),
153                    agent_id: agent_id.clone(),
154                    total_tokens: cur.context_tokens,
155                    max_tokens,
156                });
157            }
158
159            let was_terminal = prev.as_ref().map(|e| e.terminal) == Some(true);
160            if cur.terminal && !was_terminal {
161                let _ = self.events.send(WorldEvent::Completed {
162                    run_id: run_id.clone(),
163                    agent_id: agent_id.clone(),
164                    status: status.to_string(),
165                    // Read off the live entity, not off disk: this fires the
166                    // moment the run goes terminal, and the persist tick that
167                    // writes `meta.json` has not necessarily run yet.
168                    final_output: self
169                        .world
170                        .world()
171                        .get::<crate::persistence::FinalOutput>(entity)
172                        .map(|o| o.0.clone()),
173                });
174            }
175            // Unload a terminal agent once its terminal state has been emitted (a
176            // prior pass already saw it terminal, so the event went out and the
177            // persistence lane captured it) and no live parent still needs it.
178            if cur.terminal && was_terminal && self.no_live_parent(entity) {
179                let entry = self.entry_for(&run_id, entity, state);
180                to_reap.push((run_id.clone(), entity, entry));
181            }
182            // Page a paused run out of the world once its paused state is on
183            // its way to disk. Unlike `Waiting` (see the NOTE below), `Paused`
184            // carries no live continuation - it is the one non-terminal state
185            // whose whole meaning is "nothing is driving this" - and Resume,
186            // Message and Cancel all page an unloaded run back in through
187            // `resolve_or_reload`, exactly as a daemon restart would. Scoped
188            // to standalone roots: a run with tree links or an open prompt
189            // keeps the restart-equivalence question open and stays resident.
190            if self.parkable(entity, &state.status) {
191                let entry = self.entry_for(&run_id, entity, state);
192                to_park.push((run_id.clone(), entity, entry));
193            }
194            // NOTE: non-terminal `Waiting` agents are intentionally NOT unloaded.
195            // Every `Waiting` state carries a live, unpersisted continuation - a
196            // blocked `ask` future (`AwaitingInteraction`), running fan-out workers
197            // (`FanOutWaiting`), or pending children (`WaitingForChildren`) - so
198            // flushing one to disk and paging it back cannot resume it (in-flight
199            // interactions aren't persisted; the blocked future is gone). Only
200            // terminal agents (fully on disk) are reaped, and paused ones parked.
201
202            self.emitted.insert(run_id, cur);
203        }
204
205        // Reap: run the daemon's reap hook (sandbox teardown + tool-state drop)
206        // while the entity is still valid, then despawn it and erase its host-map
207        // entries. Iterating a snapshot of `by_run_id` above means removing here
208        // is safe. The reaper is moved out for the loop to avoid borrowing `self`
209        // twice, then restored.
210        let mut reaper = self.reaper.take();
211        let reaped_any = !to_reap.is_empty();
212        for (run_id, entity, entry) in to_reap {
213            if let Some(reaper) = reaper.as_mut() {
214                reaper(&mut self.world, entity);
215            }
216            self.world.world_mut().despawn(entity);
217            self.by_run_id.remove(&run_id);
218            self.emitted.remove(&run_id);
219            // The run leaves memory but not the listing: for a while yet it can
220            // still say how it ended, which is the whole of issue #205.
221            self.record_finished(entry, now);
222        }
223        // Park paused runs: same teardown as a reap (the reap hook drops the
224        // agent's tool state and sandbox, which a page-in rebuilds the way a
225        // daemon restart does), but the listing row moves to `parked` rather
226        // than `finished` - the run is not over, it is just not resident.
227        for (run_id, entity, entry) in to_park {
228            if let Some(reaper) = reaper.as_mut() {
229                reaper(&mut self.world, entity);
230            }
231            self.world.world_mut().despawn(entity);
232            self.by_run_id.remove(&run_id);
233            self.emitted.remove(&run_id);
234            self.parked.insert(run_id, entry);
235        }
236        self.reaper = reaper;
237        self.prune_finished(now);
238        // Reaped runs answer no further prompts: drop their request ids from
239        // the emitted-interaction set, which otherwise grows for the daemon's
240        // life (the set is keyed by request id, so prune by what is still
241        // pending - the same shape `cancel_tree` uses).
242        if reaped_any {
243            let still_open: std::collections::HashSet<String> = self
244                .interactions
245                .pending()
246                .into_iter()
247                .map(|(_, req)| req.id)
248                .collect();
249            self.emitted_interactions
250                .retain(|id| still_open.contains(id));
251        }
252
253        for (agent_id, request) in self.interactions.pending() {
254            if self.emitted_interactions.insert(request.id.clone()) {
255                let _ = self.events.send(WorldEvent::Interaction {
256                    run_id: agent_id.clone(),
257                    agent_id,
258                    request,
259                });
260            }
261        }
262    }
263}