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                }
80            };
81            let max_tokens = self
82                .world
83                .world()
84                .get::<ContextWindow>(entity)
85                .map(|w| w.max_tokens)
86                .unwrap_or(0);
87            let prev = self.emitted.get(&run_id).cloned();
88
89            if prev.is_none() {
90                let blueprint = self
91                    .world
92                    .world()
93                    .get::<RunMetadata>(entity)
94                    .map(|m| m.agent_name.clone())
95                    .unwrap_or_default();
96                let _ = self.events.send(WorldEvent::Spawned {
97                    run_id: run_id.clone(),
98                    agent_id: agent_id.clone(),
99                    blueprint,
100                });
101            }
102
103            let status_key = |e: &Emitted| {
104                (
105                    e.status,
106                    e.stage.clone(),
107                    e.iteration,
108                    e.tool_calls,
109                    e.accepts_messages,
110                )
111            };
112            if prev.as_ref().map(status_key) != Some(status_key(&cur)) {
113                let _ = self.events.send(WorldEvent::Status {
114                    run_id: run_id.clone(),
115                    agent_id: agent_id.clone(),
116                    status: status.to_string(),
117                    stage: cur.stage.clone(),
118                    iteration: cur.iteration,
119                    tool_calls: cur.tool_calls,
120                    accepts_messages: cur.accepts_messages,
121                });
122            }
123
124            let token_key = |e: &Emitted| {
125                (
126                    e.prompt_tokens,
127                    e.completion_tokens,
128                    e.cached_tokens,
129                    e.cache_write_tokens,
130                )
131            };
132            if prev.as_ref().map(token_key) != Some(token_key(&cur)) {
133                let _ = self.events.send(WorldEvent::Tokens {
134                    run_id: run_id.clone(),
135                    agent_id: agent_id.clone(),
136                    prompt_tokens: cur.prompt_tokens,
137                    completion_tokens: cur.completion_tokens,
138                    cached_tokens: cur.cached_tokens,
139                    cache_write_tokens: cur.cache_write_tokens,
140                });
141            }
142
143            if prev.as_ref().map(|e| e.context_tokens) != Some(cur.context_tokens) {
144                let _ = self.events.send(WorldEvent::Context {
145                    run_id: run_id.clone(),
146                    agent_id: agent_id.clone(),
147                    total_tokens: cur.context_tokens,
148                    max_tokens,
149                });
150            }
151
152            let was_terminal = prev.as_ref().map(|e| e.terminal) == Some(true);
153            if cur.terminal && !was_terminal {
154                let _ = self.events.send(WorldEvent::Completed {
155                    run_id: run_id.clone(),
156                    agent_id: agent_id.clone(),
157                    status: status.to_string(),
158                    // Read off the live entity, not off disk: this fires the
159                    // moment the run goes terminal, and the persist tick that
160                    // writes `meta.json` has not necessarily run yet.
161                    final_output: self
162                        .world
163                        .world()
164                        .get::<crate::persistence::FinalOutput>(entity)
165                        .map(|o| o.0.clone()),
166                });
167            }
168            // Unload a terminal agent once its terminal state has been emitted (a
169            // prior pass already saw it terminal, so the event went out and the
170            // persistence lane captured it) and no live parent still needs it.
171            if cur.terminal && was_terminal && self.no_live_parent(entity) {
172                let entry = self.entry_for(&run_id, entity, state);
173                to_reap.push((run_id.clone(), entity, entry));
174            }
175            // Page a paused run out of the world once its paused state is on
176            // its way to disk. Unlike `Waiting` (see the NOTE below), `Paused`
177            // carries no live continuation - it is the one non-terminal state
178            // whose whole meaning is "nothing is driving this" - and Resume,
179            // Message and Cancel all page an unloaded run back in through
180            // `resolve_or_reload`, exactly as a daemon restart would. Scoped
181            // to standalone roots: a run with tree links or an open prompt
182            // keeps the restart-equivalence question open and stays resident.
183            if self.parkable(entity, &state.status) {
184                let entry = self.entry_for(&run_id, entity, state);
185                to_park.push((run_id.clone(), entity, entry));
186            }
187            // NOTE: non-terminal `Waiting` agents are intentionally NOT unloaded.
188            // Every `Waiting` state carries a live, unpersisted continuation - a
189            // blocked `ask` future (`AwaitingInteraction`), running fan-out workers
190            // (`FanOutWaiting`), or pending children (`WaitingForChildren`) - so
191            // flushing one to disk and paging it back cannot resume it (in-flight
192            // interactions aren't persisted; the blocked future is gone). Only
193            // terminal agents (fully on disk) are reaped, and paused ones parked.
194
195            self.emitted.insert(run_id, cur);
196        }
197
198        // Reap: run the daemon's reap hook (sandbox teardown + tool-state drop)
199        // while the entity is still valid, then despawn it and erase its host-map
200        // entries. Iterating a snapshot of `by_run_id` above means removing here
201        // is safe. The reaper is moved out for the loop to avoid borrowing `self`
202        // twice, then restored.
203        let mut reaper = self.reaper.take();
204        let reaped_any = !to_reap.is_empty();
205        for (run_id, entity, entry) in to_reap {
206            if let Some(reaper) = reaper.as_mut() {
207                reaper(&mut self.world, entity);
208            }
209            self.world.world_mut().despawn(entity);
210            self.by_run_id.remove(&run_id);
211            self.emitted.remove(&run_id);
212            // The run leaves memory but not the listing: for a while yet it can
213            // still say how it ended, which is the whole of issue #205.
214            self.record_finished(entry, now);
215        }
216        // Park paused runs: same teardown as a reap (the reap hook drops the
217        // agent's tool state and sandbox, which a page-in rebuilds the way a
218        // daemon restart does), but the listing row moves to `parked` rather
219        // than `finished` - the run is not over, it is just not resident.
220        for (run_id, entity, entry) in to_park {
221            if let Some(reaper) = reaper.as_mut() {
222                reaper(&mut self.world, entity);
223            }
224            self.world.world_mut().despawn(entity);
225            self.by_run_id.remove(&run_id);
226            self.emitted.remove(&run_id);
227            self.parked.insert(run_id, entry);
228        }
229        self.reaper = reaper;
230        self.prune_finished(now);
231        // Reaped runs answer no further prompts: drop their request ids from
232        // the emitted-interaction set, which otherwise grows for the daemon's
233        // life (the set is keyed by request id, so prune by what is still
234        // pending - the same shape `cancel_tree` uses).
235        if reaped_any {
236            let still_open: std::collections::HashSet<String> = self
237                .interactions
238                .pending()
239                .into_iter()
240                .map(|(_, req)| req.id)
241                .collect();
242            self.emitted_interactions
243                .retain(|id| still_open.contains(id));
244        }
245
246        for (agent_id, request) in self.interactions.pending() {
247            if self.emitted_interactions.insert(request.id.clone()) {
248                let _ = self.events.send(WorldEvent::Interaction {
249                    run_id: agent_id.clone(),
250                    agent_id,
251                    request,
252                });
253            }
254        }
255    }
256}