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