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        // A run parked until the machine is fixed is the one that most needs
262        // explaining, so a bare `paused` would be the worst answer here: it
263        // reads as a deliberate pause somebody can undo whenever they like.
264        (AgentStatus::Paused, Some(reason)) => format!("paused: {reason}"),
265        (status, _) if entry.empty_output => format!("{status} (no output)"),
266        (status, _) => status.to_string(),
267    }
268}
269
270/// A compact age, in the largest unit that keeps the number small: `12s`, `4m`,
271/// `3h`, `2d`. Negative deltas (a clock that moved backwards) read as `0s`.
272fn humanize_age(seconds: i64) -> String {
273    let s = seconds.max(0);
274    if s < 60 {
275        format!("{s}s")
276    } else if s < 3600 {
277        format!("{}m", s / 60)
278    } else if s < 86_400 {
279        format!("{}h", s / 3600)
280    } else {
281        format!("{}d", s / 86_400)
282    }
283}
284
285/// The AGE cell: how long since the run last actually moved. A run that has not
286/// persisted a snapshot yet has nothing to measure from and reads `-`.
287fn age_cell(entry: &RunListEntry, now: i64) -> String {
288    match entry.last_progress_at {
289        Some(at) => humanize_age(now.saturating_sub(at)),
290        None => "-".to_string(),
291    }
292}
293
294/// The STAGE cell: the stage name, with its position when the blueprint has more
295/// than one stage (`implement 2/4`).
296fn stage_cell(entry: &RunListEntry) -> String {
297    match (entry.stage_index, entry.num_stages) {
298        (Some(i), Some(n)) if n > 1 => format!("{} {}/{}", entry.stage, i + 1, n),
299        _ => entry.stage.clone(),
300    }
301}
302
303/// The providers currently out of service, with why and when each is retried.
304///
305/// This is the line that would have answered issue #201 on sight. Ten runs
306/// dying in a row produced ten identical error rows and nothing that said "the
307/// OpenRouter account is empty", so the shape of the problem was invisible from
308/// the listing.
309fn providers_footer(health: &DaemonHealth) -> Option<String> {
310    if health.providers_down.is_empty() {
311        return None;
312    }
313    let each = health
314        .providers_down
315        .iter()
316        .map(|c| {
317            format!(
318                "  {} ({}, {} failures) - retrying in {}",
319                c.provider,
320                c.reason.label(),
321                c.consecutive_failures,
322                humanize_age(c.retry_in_secs as i64)
323            )
324        })
325        .collect::<Vec<_>>()
326        .join("\n");
327    let noun = match health.providers_down.len() {
328        1 => "provider is",
329        _ => "providers are",
330    };
331    Some(format!(
332        "{} {noun} out of service:\n{each}",
333        health.providers_down.len()
334    ))
335}
336
337/// The READS cell: how many of the blueprint's `[read_paths]` entries the
338/// config granted, over how many it declared. `-` for a run that declared none,
339/// which is what nearly every agent does.
340///
341/// `0/2` is the shape worth spotting: the run is up and looks healthy, and
342/// every read it was designed to make outside its workdir will be refused.
343fn reads_cell(entry: &RunListEntry) -> String {
344    match entry.read_paths {
345        Some(counts) => format!("{}/{}", counts.granted, counts.declared),
346        None => "-".to_string(),
347    }
348}
349
350/// The daemon-wide footer: what the tool lane is holding, and whether the daemon
351/// as a whole has stopped getting anywhere.
352///
353/// Absent while everything is healthy, so an ordinary listing stays a table and
354/// nothing else. A lane at capacity is worth mentioning; a dead-cycle streak is
355/// worth mentioning loudly, because every row above it can look busy while the
356/// factory as a whole has not moved in hours (issue #191).
357fn health_footer(health: &DaemonHealth) -> Option<String> {
358    let saturated = health.tools_busy >= health.tools_workers && health.tools_queued > 0;
359    if !saturated && health.dead_cycles == 0 {
360        return None;
361    }
362    let mut line = format!(
363        "lanes: tools {}/{} busy",
364        health.tools_busy, health.tools_workers
365    );
366    if health.tools_parked > 0 {
367        line.push_str(&format!(", {} parked", health.tools_parked));
368    }
369    if health.tools_queued > 0 {
370        line.push_str(&format!(", {} queued", health.tools_queued));
371    }
372    if health.dead_cycles > 0 {
373        let seconds = health.dead_cycles as i64 * health.redrive_secs as i64;
374        line.push_str(&format!(
375            "  ยท  no progress for {} cycles ({})",
376            health.dead_cycles,
377            humanize_age(seconds)
378        ));
379    }
380    Some(line)
381}
382
383/// Render a run listing as an aligned table (or a friendly note when empty),
384/// with the daemon's own health underneath when it has something to say.
385///
386/// `finished` are runs the daemon has unloaded but still remembers. They are
387/// listed after the live ones rather than left out, because "the run I started
388/// died on its first inference" and "there is no such run" are the two answers
389/// issue #205's scheduler could not tell apart, and an empty table said the
390/// second when it meant the first.
391///
392/// `now` is unix seconds, passed in rather than read here so the output is
393/// deterministic under test.
394pub fn format_runs(
395    runs: &[RunListEntry],
396    finished: &[RunListEntry],
397    health: &DaemonHealth,
398    now: i64,
399) -> String {
400    if runs.is_empty() && finished.is_empty() {
401        // "no agent runs active" on its own is the most misleading thing this
402        // command can say while a provider is down: it is what an operator sees
403        // once even the finished records have aged out, and it reads as an idle
404        // daemon rather than a factory that cannot start anything (issue #201).
405        // Say why the list is empty.
406        return match providers_footer(health) {
407            Some(footer) => format!("no agent runs active\n\n{footer}"),
408            None => "no agent runs active".to_string(),
409        };
410    }
411    // READS only appears when some run has `[read_paths]` to report, which is
412    // nearly never: an extra column of dashes on every ordinary listing would
413    // cost every reader something to buy the rare reader nothing.
414    let show_reads = runs.iter().chain(finished).any(|e| e.read_paths.is_some());
415    // Same rule as READS: a column nobody can fill costs every reader width and
416    // buys them nothing. A title exists once the run has been titled, which is
417    // shortly after it starts and never for a run whose provider refused.
418    let show_title = runs.iter().chain(finished).any(|e| e.title.is_some());
419    let mut headers = vec!["RUN"];
420    if show_title {
421        headers.push("TITLE");
422    }
423    headers.extend(["STATUS", "STAGE", "ITER", "TOOLS", "AGE"]);
424    if show_reads {
425        headers.push("READS");
426    }
427    let rows: Vec<Vec<String>> = runs
428        .iter()
429        .chain(finished)
430        .map(|e| {
431            let mut cells = vec![e.run_id.clone()];
432            if show_title {
433                cells.push(e.title.clone().unwrap_or_default());
434            }
435            cells.extend([
436                status_cell(e),
437                stage_cell(e),
438                e.iteration.to_string(),
439                e.tool_calls.to_string(),
440                age_cell(e, now),
441            ]);
442            if show_reads {
443                cells.push(reads_cell(e));
444            }
445            cells
446        })
447        .collect();
448
449    // Column widths from the header and every cell, so nothing wraps under a
450    // long run id or a long wait reason.
451    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
452    for row in &rows {
453        for (w, cell) in widths.iter_mut().zip(row) {
454            *w = (*w).max(cell.chars().count());
455        }
456    }
457
458    let render = |cells: &Vec<String>| {
459        let mut line = String::new();
460        for (i, (cell, width)) in cells.iter().zip(&widths).enumerate() {
461            if i > 0 {
462                line.push_str("  ");
463            }
464            // The last column is never padded, so lines have no trailing blanks.
465            match i == cells.len() - 1 {
466                true => line.push_str(cell),
467                false => line.push_str(&format!("{cell:<width$}")),
468            }
469        }
470        line
471    };
472
473    let header_row: Vec<String> = headers.iter().map(|h| (*h).to_string()).collect();
474    let table = std::iter::once(render(&header_row))
475        .chain(rows.iter().map(render))
476        .collect::<Vec<_>>()
477        .join("\n");
478
479    // The rows that will not move until somebody acts. Worth calling out under
480    // the table: on a wide listing they are easy to miss among the healthy
481    // `waiting: children(n)` rows they used to be indistinguishable from.
482    let blocked = runs
483        .iter()
484        .filter(|e| e.wait_reason.as_ref().is_some_and(|r| r.needs_a_person()))
485        .count();
486    let mut out = match blocked {
487        0 => table,
488        1 => format!("{table}\n\n1 run needs an answer: lev respond"),
489        n => format!("{table}\n\n{n} runs need an answer: lev respond"),
490    };
491    if let Some(footer) = providers_footer(health) {
492        out.push_str(&format!("\n\n{footer}"));
493    }
494    if let Some(footer) = health_footer(health) {
495        out.push_str(&format!("\n\n{footer}"));
496    }
497    out
498}
499
500/// Print the live listing, optionally followed by the runs on disk the daemon is
501/// not hosting. Pure formatting/serialization, so the shape is testable without
502/// a daemon.
503fn print_listing(
504    runs: &[RunListEntry],
505    finished: &[RunListEntry],
506    health: &DaemonHealth,
507    offline: Option<&[OfflineRun]>,
508    daemon_reachable: bool,
509    args: &PsArgs,
510    now: i64,
511) {
512    if args.json {
513        let mut body = serde_json::json!({ "runs": runs, "finished": finished, "health": health });
514        if let Some(offline) = offline {
515            // Only `--all` adds keys, so a plain `--json` keeps the exact shape
516            // it had before this flag existed.
517            body["daemon_reachable"] = serde_json::json!(daemon_reachable);
518            body["not_running"] = serde_json::json!(offline);
519        }
520        // Plain data with no map keys to reject, so serializing cannot fail.
521        println!(
522            "{}",
523            serde_json::to_string_pretty(&body).expect("a run listing serializes")
524        );
525        return;
526    }
527    if daemon_reachable {
528        println!("{}", format_runs(runs, finished, health, now));
529    } else {
530        println!("the leviath daemon is not reachable; showing the runs dir only");
531    }
532    if let Some(block) = offline.and_then(|o| format_offline(o, now)) {
533        println!("\n{block}");
534    }
535}
536
537/// Query the daemon for its runs and print the listing.
538///
539/// With `--all`, an unreachable daemon is reported rather than fatal. A harness
540/// polling on an interval will eventually catch the daemon restarting, and the
541/// whole point of the flag is to be the thing it reconciles against: failing
542/// there, or reporting an empty live set, would tell it every run had died at
543/// once. Without `--all` the old behavior stands, because a listing of live runs
544/// with no daemon to list them is simply an error.
545pub async fn send_list(client: &ControlClient, args: &PsArgs) -> anyhow::Result<()> {
546    let now = chrono::Utc::now().timestamp();
547    match (client.list().await, args.all) {
548        (
549            Ok(ControlResponse::List {
550                runs,
551                finished,
552                health,
553            }),
554            all,
555        ) => {
556            // Both halves of the daemon's answer are already on screen, so the
557            // disk block subtracts both rather than listing them twice. A run in
558            // `finished` is terminal on disk anyway, so this cannot change an
559            // abandoned verdict, only avoid a duplicate row.
560            let shown: std::collections::HashSet<String> = runs
561                .iter()
562                .chain(finished.iter())
563                .map(|r| r.run_id.clone())
564                .collect();
565            let offline = all.then(|| offline_runs(runstate::list_runs(), Some(&shown), now));
566            print_listing(
567                &runs,
568                &finished,
569                &health,
570                offline.as_deref(),
571                true,
572                args,
573                now,
574            );
575            Ok(())
576        }
577        (Ok(other), _) => bail!("unexpected daemon response: {other:?}"),
578        (Err(_), true) => {
579            let offline = offline_runs(runstate::list_runs(), None, now);
580            print_listing(
581                &[],
582                &[],
583                &DaemonHealth::default(),
584                Some(&offline),
585                false,
586                args,
587                now,
588            );
589            Ok(())
590        }
591        (Err(e), false) => {
592            bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`")
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests;