Skip to main content

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