Skip to main content

leviath_cli/commands/
ps.rs

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