leviath_runtime/host/listing.rs
1//! What `lev ps` and the control socket see: one row per run, live from the
2//! world rather than re-read from disk.
3//!
4//! [`wait_reason`](WorldHost::wait_reason) is the interesting part. A status of
5//! `Waiting` says nothing about whether a person is needed, which is what issue
6//! #184 was about, so the row carries why.
7//!
8//! Retention lives here too, and deliberately: a finished run stays listable for
9//! a while after it ends, so how long the list keeps one is a property of the
10//! list rather than of the health bookkeeping it used to sit beside.
11
12use super::*;
13
14impl WorldHost {
15 /// Why `entity` is [`AgentStatus::Waiting`], read off the markers the engine
16 /// already maintains. `None` when the agent is not waiting, or when it is
17 /// waiting for a reason nothing has claimed.
18 ///
19 /// Order matters. A taint-gate block and a stage checkpoint each open a hub
20 /// request of their own, so both also carry [`AwaitingInteraction`]; asking
21 /// the specific markers first is what keeps them from all reporting as a
22 /// generic prompt.
23 pub fn wait_reason(&self, agent: crate::world::AgentId) -> Option<WaitReason> {
24 let world = self.world.world();
25 // An id from another world names a different agent here, which would
26 // report that one's wait reason as this run's.
27 let entity = agent.resolve_in(world)?;
28 let state = world.get::<AgentState>(entity)?;
29 // The precedence itself lives in `leviath_core`, shared with the
30 // persistence system that writes the same answer to `meta.json`: two
31 // copies of it would disagree the first time either was edited.
32 leviath_core::run_meta::wait_reason_from(
33 matches!(state.status, AgentStatus::Waiting | AgentStatus::Paused),
34 &leviath_core::run_meta::WaitMarkers {
35 gate_prompt: world
36 .get::<crate::gate_prompt::AwaitingGatePrompt>(entity)
37 .is_some(),
38 interaction_point: world
39 .get::<crate::interaction_points::AwaitingInteractionPoint>(entity)
40 .is_some(),
41 fan_out_outstanding: world
42 .get::<crate::fanout::FanOutWaiting>(entity)
43 .map(|f| f.outstanding()),
44 children_outstanding: world
45 .get::<crate::pipeline::WaitingForChildren>(entity)
46 .map(|_| {
47 world
48 .get::<SubAgentChildren>(entity)
49 .map(|c| {
50 c.children
51 .iter()
52 .filter(|&&child| {
53 world.get::<AgentState>(child).is_some_and(|s| {
54 !crate::pipeline::is_terminal_status(&s.status)
55 })
56 })
57 .count()
58 })
59 .unwrap_or(0)
60 }),
61 // The hub is keyed by agent id, and one agent can only be
62 // parked on one prompt at a time, so the first match is the
63 // one blocking it.
64 interaction: self
65 .interactions
66 .pending()
67 .into_iter()
68 .find(|(agent_id, _)| *agent_id == state.agent_id)
69 .map(|(_, req)| req.kind),
70 awaiting_interaction: world.get::<AwaitingInteraction>(entity).is_some(),
71 needs_setup: world
72 .get::<crate::pipeline::PausedForSetup>(entity)
73 .map(|p| leviath_core::run_meta::SetupNeeded {
74 blocker: p.blocker,
75 remedy: p.remedy.clone(),
76 }),
77 },
78 )
79 }
80
81 /// One listing row for a run, read off the live world.
82 ///
83 /// Shared by [`Self::list`] and by the unload path in [`Self::emit_events`],
84 /// so a run's last row is built exactly the way every row before it was.
85 /// Takes the state rather than looking it up because the unload path already
86 /// holds one, and a `None` it could never return would be a branch nothing
87 /// can reach.
88 pub(super) fn entry_for(
89 &self,
90 run_id: &str,
91 entity: Entity,
92 state: &AgentState,
93 ) -> RunListEntry {
94 let world = self.world.world();
95 let metadata = world.get::<RunMetadata>(entity);
96 let has_output = world
97 .get::<crate::persistence::FinalOutput>(entity)
98 .is_some();
99 RunListEntry {
100 run_id: run_id.to_string(),
101 title: metadata.and_then(|m| m.title.clone()),
102 status: state.status.clone(),
103 wait_reason: self.wait_reason(crate::world::AgentId::in_world(world, entity)),
104 stage: state.current_stage.clone(),
105 stage_index: world
106 .get::<crate::pipeline::StageCursor>(entity)
107 .map(|c| c.index),
108 num_stages: metadata.map(|m| m.num_stages),
109 iteration: state.iteration,
110 tool_calls: world.get::<TokenTotals>(entity).map_or(0, |t| t.tool_calls),
111 last_progress_at: world
112 .get::<crate::pipeline::PersistWatermark>(entity)
113 .and_then(|w| w.last_progress_at()),
114 unattended: metadata.is_some_and(|m| m.unattended),
115 empty_output: world
116 .get::<crate::persistence::RunOutcomeFlags>(entity)
117 .is_some_and(|f| {
118 // `produced_output` lives on the component only after a
119 // persist tick fills it, so it is answered from the live
120 // entity here. Without this, a researcher that submitted a
121 // perfectly good answer still read `complete (no output)`
122 // in `lev ps` while `meta.json` said otherwise - the exact
123 // drift between the two surfaces that one shared
124 // `is_empty_output` exists to prevent.
125 let mut flags = f.0.clone();
126 flags.produced_output = has_output;
127 crate::persistence::is_empty_output(&state.status, &flags)
128 }),
129 read_paths: metadata.and_then(|m| m.read_paths),
130 has_final_output: has_output,
131 }
132 }
133
134 /// List every known live run with the context an operator needs to read its
135 /// status: why it is waiting, where it is, and when it last moved.
136 pub(super) fn list(&self) -> Vec<RunListEntry> {
137 let world = self.world.world();
138 self.by_run_id
139 .iter()
140 .filter_map(|(run_id, &agent)| {
141 let state = world.get::<AgentState>(agent.entity())?;
142 Some(self.entry_for(run_id, agent.entity(), state))
143 })
144 // Parked (paused, paged-out) runs are still the daemon's runs; an
145 // operator must not lose sight of one just because it left memory.
146 .chain(self.parked.values().cloned())
147 .collect()
148 }
149
150 /// The runs unloaded recently enough to still be reported, oldest first.
151 ///
152 /// Kept apart from [`Self::list`] rather than folded into it because
153 /// "running now" and "finished a moment ago" are different questions, and
154 /// two callers already depend on the first one: `lev daemon status` counts
155 /// the hosted agents, and the dashboard uses the listing to decide which
156 /// runs the daemon still holds.
157 pub(super) fn finished(&self) -> Vec<RunListEntry> {
158 self.finished
159 .iter()
160 .map(|(_, entry)| entry.clone())
161 .collect()
162 }
163
164 /// How long a run stays in the listing after the daemon unloads it. `0`
165 /// keeps none, which is how the listing behaved before issue #205. Served
166 /// from `[limits] finished_retention_secs`.
167 pub fn set_finished_retention_secs(&mut self, secs: u64) {
168 self.finished_retention_secs = secs;
169 }
170
171 /// Keep `entry` in the listing as a run that finished at `at`.
172 ///
173 /// One row per run: an id already held is replaced rather than duplicated,
174 /// so however often a run is unloaded it is reported once.
175 ///
176 /// `last_progress_at` is filled in from `at` when the run never persisted a
177 /// snapshot. That is not a guess. A run that died on its first inference has
178 /// no watermark to read, and the listing would show its age as `-` - which
179 /// is the one thing an operator or a scheduler most wants to know about a
180 /// run that failed instantly. For a run being unloaded, the unload is the
181 /// last thing that happened to it.
182 pub(super) fn record_finished(&mut self, mut entry: RunListEntry, at: i64) {
183 if self.finished_retention_secs == 0 {
184 return;
185 }
186 entry.last_progress_at.get_or_insert(at);
187 self.finished
188 .retain(|(_, held)| held.run_id != entry.run_id);
189 self.finished.push_back((at, entry));
190 while self.finished.len() > MAX_RETAINED_FINISHED {
191 self.finished.pop_front();
192 }
193 }
194
195 /// Drop unloaded runs that have outlived the retention window.
196 ///
197 /// `now` is passed in rather than read here so a test can age the buffer
198 /// without sleeping through the window, the same reason `lev ps`'s
199 /// `format_runs` takes it. Called once per [`Self::emit_events`], which the
200 /// serve loop runs before it handles any control op, so a listing never has
201 /// to prune on the way out.
202 pub(super) fn prune_finished(&mut self, now: i64) {
203 let window = self.finished_retention_secs as i64;
204 while let Some(&(at, _)) = self.finished.front() {
205 if now.saturating_sub(at) <= window {
206 break;
207 }
208 self.finished.pop_front();
209 }
210 }
211}