Skip to main content

leviath_cli/commands/
ps.rs

1//! `lev ps` - list the agents running in the shared-world daemon.
2//!
3//! Queries the daemon over its control socket and prints one line per run. The
4//! query + formatting cores are tested here; the socket-path resolution + connect
5//! live in the binary behind [`crate::dispatch::RiskyExecutors`].
6
7use anyhow::bail;
8use leviath_core::run_meta::{RunMeta, RunStatus};
9use leviath_runtime::components::AgentStatus;
10use leviath_runtime::control_socket::{ControlClient, ControlResponse};
11use leviath_runtime::host::{DaemonHealth, RunListEntry};
12use serde::{Deserialize, Serialize};
13
14use crate::runstate;
15
16/// `lev ps --help`. Every status an operator can see, and what to do about it.
17pub const PS_LONG_ABOUT: &str = "\
18List agents running in the shared-world daemon.
19
20Columns: RUN, STATUS, STAGE (with position when the blueprint has several),
21ITER (iterations in the current stage), TOOLS (tool calls so far), and AGE.
22
23READS appears only when some listed run's blueprint declares [read_paths], and
24reads granted/declared. A blueprint declaring paths outside its workdir is not
25the same as being allowed to read them: your config.toml has to grant them too,
26so `0/2` means the run is up and every such read will be refused. `lev validate
27<agent>` names the entries and prints the stanza to add.
28
29AGE is how long since the run last actually moved - a new iteration, a new
30stage, or a change of status. It is not the `updated_at` in meta.json, which
31also advances on a 30-second heartbeat and so stays fresh on a wedged run.
32
33Statuses:
34  active     running a turn, or waiting on the model or a tool
35  idle       spawned, not yet started
36  paused     paused with `lev pause`; resume with `lev resume`
37  waiting    blocked - see the reason after the colon
38  complete   finished
39  cancelled  cancelled with `lev kill`
40  error      ended with the error shown
41
42A finished run marked `(no output)` changed no files, though its agent had a
43tool to change them with. Usually the work went through the shell, which the
44framework cannot see: edits made with `sed -i`, `tee` or a redirect are not
45recorded, so re-apply them with `edit_file` or `write_file`. Agents that never
46had a file-writing tool - a router, a researcher - are never marked this way.
47
48A `waiting` run says what it is blocked on. These need a person:
49  tool approval  a tool call needs approving; answer with `lev respond`
50  user prompt    the agent asked a question (ask_user_*); answer it
51  taint gate     a call needs clearance for the data it touches
52  checkpoint     a blueprint stage-boundary review
53
54These do not - the run is parked on other work and resumes by itself:
55  workers(n)     a fan-out parent, n workers still to finish
56  children(n)    a stage holding for n spawned sub-agents
57
58So `waiting: children(3)` alongside busy children is a healthy factory, while
59`waiting: tool approval` is stopped until someone answers. Run with `--yolo` to
60approve automatically, including for sub-agents and fan-out workers.
61
62A run stays listed for a few minutes after it finishes, so a script polling on
63an interval learns how a run ended rather than finding it gone. Set
64`[limits] finished_retention_secs` to change the window, or 0 to drop a run the
65moment it finishes. The record is held in memory, so a daemon restart clears it;
66`meta.json` and the REST API keep the durable copy.
67
68An `out of service` block under the table lists providers the daemon has stopped
69sending work to, because each failed several times in a row for something only
70you can fix: an account out of credits, or a key that was rejected. Runs move to
71the next provider a stage lists (or one from `[providers] fallback_order`); a run
72with none left is failed rather than left waiting. Each entry says how long until
73that provider is tried again, and topping up the account needs no restart.
74
75A `lanes:` line under the table means the daemon itself is worth a look. It
76shows the tool lane's occupancy - batches running, parked on a wait, and queued
77behind them - and, if the daemon has stopped getting anywhere, how many re-drive
78cycles it has gone without a single run moving. A run parked on a wait costs the
79lane nothing, so `parked` is not a problem on its own; `queued` with no progress
80is.
81
82--json prints {\"runs\": [...], \"finished\": [...], \"health\": {...}}, keeping
83finished runs apart from the ones the daemon is still hosting. A row's
84\"has_final_output\" says whether the agent handed something back; read the
85answer itself with `lev result <run-id>` (it can be large, so it is not
86inlined here).
87
88--all adds a NOT RUNNING block, read from the runs dir rather than the daemon's
89memory. The retention window above covers the minutes after a run ends; this
90covers the rest of time, and survives a daemon restart. A row marked
91`(abandoned)` claims on disk to be running, is not held by the daemon, and has
92not moved in five minutes - clear it with `lev cancel --force <run-id>`.
93
94With --all the daemon being down is reported rather than fatal, and nothing is
95marked abandoned in that case, because an unreachable daemon looks exactly like
96every run dying at once. --all --json adds \"daemon_reachable\" and
97\"not_running\"; without --all the JSON is unchanged. Reading the runs dir costs
98a file per run and nothing prunes it, so poll --all less often than plain ps.";
99
100/// Arguments for `lev ps`.
101#[derive(clap::Args, Debug, Clone, Default)]
102pub struct PsArgs {
103    /// Print the raw listing as JSON instead of a table.
104    #[arg(long)]
105    pub json: bool,
106    /// Also list runs on disk that the daemon is not hosting, including
107    /// finished ones. For reconciling an external queue against Leviath.
108    #[arg(long)]
109    pub all: bool,
110}
111
112/// How many `NOT RUNNING` rows the table shows before it summarizes the rest.
113///
114/// The table is for a person, and a long-lived runs dir holds thousands. `--json`
115/// is uncapped, because that is what a reconciler reads.
116const OFFLINE_TABLE_LIMIT: usize = 20;
117
118/// One run that exists on disk but which the daemon is not currently hosting.
119///
120/// Deliberately not a [`RunListEntry`]. That type describes a live agent, and
121/// there is no honest way to turn a persisted [`RunStatus`] back into an
122/// `AgentStatus`: `Starting` and `CompleteInteractive` have no counterpart, and
123/// `Idle`/`Active` both collapse to `Running` on the way out. Inventing a live
124/// status for a run nobody is running is the exact kind of convenient lie that
125/// made issue #202 hard to diagnose, so the two sources stay in two arrays, each
126/// honest about where it came from.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct OfflineRun {
129    /// The run id.
130    pub run_id: String,
131    /// The status recorded on disk, verbatim.
132    pub status: RunStatus,
133    /// The recorded error, for a run that ended badly.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub error: Option<String>,
136    /// Unix seconds when the run started.
137    pub started_at: i64,
138    /// Unix seconds of the last snapshot, heartbeat included. Not progress.
139    pub updated_at: i64,
140    /// Unix seconds when the run last actually moved, when it is known.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub last_progress_at: Option<i64>,
143    /// Whether the run finished having modified nothing, when it could have.
144    #[serde(default)]
145    pub empty_output: bool,
146    /// Whether the run submitted a final output. The flag only; read the answer
147    /// itself with `lev result <run-id>`.
148    #[serde(default)]
149    pub has_final_output: bool,
150    /// Disk says this run is still going, and the daemon is not hosting it, and
151    /// it has not moved in a long time. See [`runstate::looks_abandoned`].
152    ///
153    /// Never true when the daemon did not answer: an unreachable daemon looks
154    /// exactly like every run dying at once.
155    pub abandoned: bool,
156}
157
158/// The runs on disk that `live` does not account for, newest first.
159///
160/// `live` is `None` when the daemon gave no answer, in which case every run on
161/// disk is reported (there is no live set to subtract) and none is judged.
162pub fn offline_runs(
163    on_disk: Vec<RunMeta>,
164    live: Option<&std::collections::HashSet<String>>,
165    now: i64,
166) -> Vec<OfflineRun> {
167    on_disk
168        .into_iter()
169        .filter(|m| !live.is_some_and(|l| l.contains(&m.run_id)))
170        .map(|m| OfflineRun {
171            abandoned: runstate::looks_abandoned(&m, live, now),
172            run_id: m.run_id,
173            status: m.status,
174            error: m.error,
175            started_at: m.started_at,
176            updated_at: m.updated_at,
177            last_progress_at: m.last_progress_at,
178            empty_output: m.flags.empty_output,
179            has_final_output: m.final_output.is_some(),
180        })
181        .collect()
182}
183
184/// The status cell for a run the daemon is not hosting: the persisted status,
185/// plus why it is worth looking at.
186fn offline_status_cell(run: &OfflineRun) -> String {
187    let status = run.status.to_string().to_lowercase();
188    if run.abandoned {
189        return format!("{status} (abandoned)");
190    }
191    match run.empty_output {
192        true => format!("{status} (no output)"),
193        false => status,
194    }
195}
196
197/// Render the `NOT RUNNING` block. `None` when there is nothing to show.
198pub fn format_offline(runs: &[OfflineRun], now: i64) -> Option<String> {
199    if runs.is_empty() {
200        return None;
201    }
202    let shown = runs.len().min(OFFLINE_TABLE_LIMIT);
203    let headers = ["RUN", "STATUS", "LAST MOVED"];
204    let rows: Vec<[String; 3]> = runs[..shown]
205        .iter()
206        .map(|r| {
207            [
208                r.run_id.clone(),
209                offline_status_cell(r),
210                humanize_age(now.saturating_sub(r.last_progress_at.unwrap_or(r.updated_at))),
211            ]
212        })
213        .collect();
214
215    let mut widths = headers.map(str::len);
216    for row in &rows {
217        for (w, cell) in widths.iter_mut().zip(row) {
218            *w = (*w).max(cell.chars().count());
219        }
220    }
221    let render = |cells: &[String; 3]| {
222        let mut line = String::new();
223        for (i, (cell, width)) in cells.iter().zip(widths).enumerate() {
224            if i > 0 {
225                line.push_str("  ");
226            }
227            match i == cells.len() - 1 {
228                true => line.push_str(cell),
229                false => line.push_str(&format!("{cell:<width$}")),
230            }
231        }
232        line
233    };
234
235    let header_row = headers.map(str::to_string);
236    let mut out = std::iter::once("NOT RUNNING".to_string())
237        .chain(std::iter::once(render(&header_row)))
238        .chain(rows.iter().map(render))
239        .collect::<Vec<_>>()
240        .join("\n");
241    if runs.len() > shown {
242        out.push_str(&format!("\n+{} older", runs.len() - shown));
243    }
244    Some(out)
245}
246
247/// The status cell for a run: the status word, plus what it is waiting on when
248/// that is the difference between "leave it alone" and "go answer it", or a
249/// note that a finished run has nothing to show for itself.
250///
251/// A run that ends having changed nothing looks identical to a successful one
252/// from the outside, which is how a whole batch of them can go unnoticed - the
253/// failure that produced issue #107 in the first place.
254fn status_cell(entry: &RunListEntry) -> String {
255    match (&entry.status, &entry.wait_reason) {
256        (AgentStatus::Waiting, Some(reason)) => format!("waiting: {reason}"),
257        (status, _) if entry.empty_output => format!("{status} (no output)"),
258        (status, _) => status.to_string(),
259    }
260}
261
262/// A compact age, in the largest unit that keeps the number small: `12s`, `4m`,
263/// `3h`, `2d`. Negative deltas (a clock that moved backwards) read as `0s`.
264fn humanize_age(seconds: i64) -> String {
265    let s = seconds.max(0);
266    if s < 60 {
267        format!("{s}s")
268    } else if s < 3600 {
269        format!("{}m", s / 60)
270    } else if s < 86_400 {
271        format!("{}h", s / 3600)
272    } else {
273        format!("{}d", s / 86_400)
274    }
275}
276
277/// The AGE cell: how long since the run last actually moved. A run that has not
278/// persisted a snapshot yet has nothing to measure from and reads `-`.
279fn age_cell(entry: &RunListEntry, now: i64) -> String {
280    match entry.last_progress_at {
281        Some(at) => humanize_age(now.saturating_sub(at)),
282        None => "-".to_string(),
283    }
284}
285
286/// The STAGE cell: the stage name, with its position when the blueprint has more
287/// than one stage (`implement 2/4`).
288fn stage_cell(entry: &RunListEntry) -> String {
289    match (entry.stage_index, entry.num_stages) {
290        (Some(i), Some(n)) if n > 1 => format!("{} {}/{}", entry.stage, i + 1, n),
291        _ => entry.stage.clone(),
292    }
293}
294
295/// The providers currently out of service, with why and when each is retried.
296///
297/// This is the line that would have answered issue #201 on sight. Ten runs
298/// dying in a row produced ten identical error rows and nothing that said "the
299/// OpenRouter account is empty", so the shape of the problem was invisible from
300/// the listing.
301fn providers_footer(health: &DaemonHealth) -> Option<String> {
302    if health.providers_down.is_empty() {
303        return None;
304    }
305    let each = health
306        .providers_down
307        .iter()
308        .map(|c| {
309            format!(
310                "  {} ({}, {} failures) - retrying in {}",
311                c.provider,
312                c.reason.label(),
313                c.consecutive_failures,
314                humanize_age(c.retry_in_secs as i64)
315            )
316        })
317        .collect::<Vec<_>>()
318        .join("\n");
319    let noun = match health.providers_down.len() {
320        1 => "provider is",
321        _ => "providers are",
322    };
323    Some(format!(
324        "{} {noun} out of service:\n{each}",
325        health.providers_down.len()
326    ))
327}
328
329/// The READS cell: how many of the blueprint's `[read_paths]` entries the
330/// config granted, over how many it declared. `-` for a run that declared none,
331/// which is what nearly every agent does.
332///
333/// `0/2` is the shape worth spotting: the run is up and looks healthy, and
334/// every read it was designed to make outside its workdir will be refused.
335fn reads_cell(entry: &RunListEntry) -> String {
336    match entry.read_paths {
337        Some(counts) => format!("{}/{}", counts.granted, counts.declared),
338        None => "-".to_string(),
339    }
340}
341
342/// The daemon-wide footer: what the tool lane is holding, and whether the daemon
343/// as a whole has stopped getting anywhere.
344///
345/// Absent while everything is healthy, so an ordinary listing stays a table and
346/// nothing else. A lane at capacity is worth mentioning; a dead-cycle streak is
347/// worth mentioning loudly, because every row above it can look busy while the
348/// factory as a whole has not moved in hours (issue #191).
349fn health_footer(health: &DaemonHealth) -> Option<String> {
350    let saturated = health.tools_busy >= health.tools_workers && health.tools_queued > 0;
351    if !saturated && health.dead_cycles == 0 {
352        return None;
353    }
354    let mut line = format!(
355        "lanes: tools {}/{} busy",
356        health.tools_busy, health.tools_workers
357    );
358    if health.tools_parked > 0 {
359        line.push_str(&format!(", {} parked", health.tools_parked));
360    }
361    if health.tools_queued > 0 {
362        line.push_str(&format!(", {} queued", health.tools_queued));
363    }
364    if health.dead_cycles > 0 {
365        let seconds = health.dead_cycles as i64 * health.redrive_secs as i64;
366        line.push_str(&format!(
367            "  ยท  no progress for {} cycles ({})",
368            health.dead_cycles,
369            humanize_age(seconds)
370        ));
371    }
372    Some(line)
373}
374
375/// Render a run listing as an aligned table (or a friendly note when empty),
376/// with the daemon's own health underneath when it has something to say.
377///
378/// `finished` are runs the daemon has unloaded but still remembers. They are
379/// listed after the live ones rather than left out, because "the run I started
380/// died on its first inference" and "there is no such run" are the two answers
381/// issue #205's scheduler could not tell apart, and an empty table said the
382/// second when it meant the first.
383///
384/// `now` is unix seconds, passed in rather than read here so the output is
385/// deterministic under test.
386pub fn format_runs(
387    runs: &[RunListEntry],
388    finished: &[RunListEntry],
389    health: &DaemonHealth,
390    now: i64,
391) -> String {
392    if runs.is_empty() && finished.is_empty() {
393        // "no agents running" on its own is the most misleading thing this
394        // command can say while a provider is down: it is what an operator sees
395        // once even the finished records have aged out, and it reads as an idle
396        // daemon rather than a factory that cannot start anything (issue #201).
397        // Say why the list is empty.
398        return match providers_footer(health) {
399            Some(footer) => format!("no agents running\n\n{footer}"),
400            None => "no agents running".to_string(),
401        };
402    }
403    // READS only appears when some run has `[read_paths]` to report, which is
404    // nearly never: an extra column of dashes on every ordinary listing would
405    // cost every reader something to buy the rare reader nothing.
406    let show_reads = runs.iter().chain(finished).any(|e| e.read_paths.is_some());
407    let mut headers = vec!["RUN", "STATUS", "STAGE", "ITER", "TOOLS", "AGE"];
408    if show_reads {
409        headers.push("READS");
410    }
411    let rows: Vec<Vec<String>> = runs
412        .iter()
413        .chain(finished)
414        .map(|e| {
415            let mut cells = vec![
416                e.run_id.clone(),
417                status_cell(e),
418                stage_cell(e),
419                e.iteration.to_string(),
420                e.tool_calls.to_string(),
421                age_cell(e, now),
422            ];
423            if show_reads {
424                cells.push(reads_cell(e));
425            }
426            cells
427        })
428        .collect();
429
430    // Column widths from the header and every cell, so nothing wraps under a
431    // long run id or a long wait reason.
432    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
433    for row in &rows {
434        for (w, cell) in widths.iter_mut().zip(row) {
435            *w = (*w).max(cell.chars().count());
436        }
437    }
438
439    let render = |cells: &Vec<String>| {
440        let mut line = String::new();
441        for (i, (cell, width)) in cells.iter().zip(&widths).enumerate() {
442            if i > 0 {
443                line.push_str("  ");
444            }
445            // The last column is never padded, so lines have no trailing blanks.
446            match i == cells.len() - 1 {
447                true => line.push_str(cell),
448                false => line.push_str(&format!("{cell:<width$}")),
449            }
450        }
451        line
452    };
453
454    let header_row: Vec<String> = headers.iter().map(|h| (*h).to_string()).collect();
455    let table = std::iter::once(render(&header_row))
456        .chain(rows.iter().map(render))
457        .collect::<Vec<_>>()
458        .join("\n");
459
460    // The rows that will not move until somebody acts. Worth calling out under
461    // the table: on a wide listing they are easy to miss among the healthy
462    // `waiting: children(n)` rows they used to be indistinguishable from.
463    let blocked = runs
464        .iter()
465        .filter(|e| e.wait_reason.as_ref().is_some_and(|r| r.needs_a_person()))
466        .count();
467    let mut out = match blocked {
468        0 => table,
469        1 => format!("{table}\n\n1 run needs an answer: lev respond"),
470        n => format!("{table}\n\n{n} runs need an answer: lev respond"),
471    };
472    if let Some(footer) = providers_footer(health) {
473        out.push_str(&format!("\n\n{footer}"));
474    }
475    if let Some(footer) = health_footer(health) {
476        out.push_str(&format!("\n\n{footer}"));
477    }
478    out
479}
480
481/// Print the live listing, optionally followed by the runs on disk the daemon is
482/// not hosting. Pure formatting/serialization, so the shape is testable without
483/// a daemon.
484fn print_listing(
485    runs: &[RunListEntry],
486    finished: &[RunListEntry],
487    health: &DaemonHealth,
488    offline: Option<&[OfflineRun]>,
489    daemon_reachable: bool,
490    args: &PsArgs,
491    now: i64,
492) {
493    if args.json {
494        let mut body = serde_json::json!({ "runs": runs, "finished": finished, "health": health });
495        if let Some(offline) = offline {
496            // Only `--all` adds keys, so a plain `--json` keeps the exact shape
497            // it had before this flag existed.
498            body["daemon_reachable"] = serde_json::json!(daemon_reachable);
499            body["not_running"] = serde_json::json!(offline);
500        }
501        // Plain data with no map keys to reject, so serializing cannot fail.
502        println!(
503            "{}",
504            serde_json::to_string_pretty(&body).expect("a run listing serializes")
505        );
506        return;
507    }
508    if daemon_reachable {
509        println!("{}", format_runs(runs, finished, health, now));
510    } else {
511        println!("the leviath daemon is not reachable; showing the runs dir only");
512    }
513    if let Some(block) = offline.and_then(|o| format_offline(o, now)) {
514        println!("\n{block}");
515    }
516}
517
518/// Query the daemon for its runs and print the listing.
519///
520/// With `--all`, an unreachable daemon is reported rather than fatal. A harness
521/// polling on an interval will eventually catch the daemon restarting, and the
522/// whole point of the flag is to be the thing it reconciles against: failing
523/// there, or reporting an empty live set, would tell it every run had died at
524/// once. Without `--all` the old behavior stands, because a listing of live runs
525/// with no daemon to list them is simply an error.
526pub async fn send_list(client: &ControlClient, args: &PsArgs) -> anyhow::Result<()> {
527    let now = chrono::Utc::now().timestamp();
528    match (client.list().await, args.all) {
529        (
530            Ok(ControlResponse::List {
531                runs,
532                finished,
533                health,
534            }),
535            all,
536        ) => {
537            // Both halves of the daemon's answer are already on screen, so the
538            // disk block subtracts both rather than listing them twice. A run in
539            // `finished` is terminal on disk anyway, so this cannot change an
540            // abandoned verdict, only avoid a duplicate row.
541            let shown: std::collections::HashSet<String> = runs
542                .iter()
543                .chain(finished.iter())
544                .map(|r| r.run_id.clone())
545                .collect();
546            let offline = all.then(|| offline_runs(runstate::list_runs(), Some(&shown), now));
547            print_listing(
548                &runs,
549                &finished,
550                &health,
551                offline.as_deref(),
552                true,
553                args,
554                now,
555            );
556            Ok(())
557        }
558        (Ok(other), _) => bail!("unexpected daemon response: {other:?}"),
559        (Err(_), true) => {
560            let offline = offline_runs(runstate::list_runs(), None, now);
561            print_listing(
562                &[],
563                &[],
564                &DaemonHealth::default(),
565                Some(&offline),
566                false,
567                args,
568                now,
569            );
570            Ok(())
571        }
572        (Err(e), false) => {
573            bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`")
574        }
575    }
576}
577
578#[cfg(test)]
579mod tests;