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}