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}