use anyhow::bail;
use leviath_core::run_meta::{RunMeta, RunStatus};
use leviath_runtime::components::AgentStatus;
use leviath_runtime::control_socket::{ControlClient, ControlResponse};
use leviath_runtime::host::{DaemonHealth, RunListEntry};
use serde::{Deserialize, Serialize};
use crate::runstate;
pub const PS_LONG_ABOUT: &str = "\
List agents running in the shared-world daemon.
Columns: RUN, STATUS, STAGE (with position when the blueprint has several),
ITER (iterations in the current stage), TOOLS (tool calls so far), and AGE.
READS appears only when some listed run's blueprint declares [read_paths], and
reads granted/declared. A blueprint declaring paths outside its workdir is not
the same as being allowed to read them: your config.toml has to grant them too,
so `0/2` means the run is up and every such read will be refused. `lev validate
<agent>` names the entries and prints the stanza to add.
AGE is how long since the run last actually moved - a new iteration, a new
stage, or a change of status. It is not the `updated_at` in meta.json, which
also advances on a 30-second heartbeat and so stays fresh on a wedged run.
Statuses:
active running a turn, or waiting on the model or a tool
idle spawned, not yet started
paused paused with `lev pause`; resume with `lev resume`
waiting blocked - see the reason after the colon
complete finished
cancelled cancelled with `lev kill`
error ended with the error shown
A finished run marked `(no output)` changed no files, though its agent had a
tool to change them with. Usually the work went through the shell, which the
framework cannot see: edits made with `sed -i`, `tee` or a redirect are not
recorded, so re-apply them with `edit_file` or `write_file`. Agents that never
had a file-writing tool - a router, a researcher - are never marked this way.
A `waiting` run says what it is blocked on. These need a person:
tool approval a tool call needs approving; answer with `lev respond`
user prompt the agent asked a question (ask_user_*); answer it
taint gate a call needs clearance for the data it touches
checkpoint a blueprint stage-boundary review
These do not - the run is parked on other work and resumes by itself:
workers(n) a fan-out parent, n workers still to finish
children(n) a stage holding for n spawned sub-agents
So `waiting: children(3)` alongside busy children is a healthy factory, while
`waiting: tool approval` is stopped until someone answers. Run with `--yolo` to
approve automatically, including for sub-agents and fan-out workers.
A run stays listed for a few minutes after it finishes, so a script polling on
an interval learns how a run ended rather than finding it gone. Set
`[limits] finished_retention_secs` to change the window, or 0 to drop a run the
moment it finishes. The record is held in memory, so a daemon restart clears it;
`meta.json` and the REST API keep the durable copy.
An `out of service` block under the table lists providers the daemon has stopped
sending work to, because each failed several times in a row for something only
you can fix: an account out of credits, or a key that was rejected. Runs move to
the next provider a stage lists (or one from `[providers] fallback_order`); a run
with none left is failed rather than left waiting. Each entry says how long until
that provider is tried again, and topping up the account needs no restart.
A `lanes:` line under the table means the daemon itself is worth a look. It
shows the tool lane's occupancy - batches running, parked on a wait, and queued
behind them - and, if the daemon has stopped getting anywhere, how many re-drive
cycles it has gone without a single run moving. A run parked on a wait costs the
lane nothing, so `parked` is not a problem on its own; `queued` with no progress
is.
--json prints {\"runs\": [...], \"finished\": [...], \"health\": {...}}, keeping
finished runs apart from the ones the daemon is still hosting.
--all adds a NOT RUNNING block, read from the runs dir rather than the daemon's
memory. The retention window above covers the minutes after a run ends; this
covers the rest of time, and survives a daemon restart. A row marked
`(abandoned)` claims on disk to be running, is not held by the daemon, and has
not moved in five minutes - clear it with `lev cancel --force <run-id>`.
With --all the daemon being down is reported rather than fatal, and nothing is
marked abandoned in that case, because an unreachable daemon looks exactly like
every run dying at once. --all --json adds \"daemon_reachable\" and
\"not_running\"; without --all the JSON is unchanged. Reading the runs dir costs
a file per run and nothing prunes it, so poll --all less often than plain ps.";
#[derive(clap::Args, Debug, Clone, Default)]
pub struct PsArgs {
#[arg(long)]
pub json: bool,
#[arg(long)]
pub all: bool,
}
const OFFLINE_TABLE_LIMIT: usize = 20;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OfflineRun {
pub run_id: String,
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub started_at: i64,
pub updated_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_progress_at: Option<i64>,
#[serde(default)]
pub empty_output: bool,
pub abandoned: bool,
}
pub fn offline_runs(
on_disk: Vec<RunMeta>,
live: Option<&std::collections::HashSet<String>>,
now: i64,
) -> Vec<OfflineRun> {
on_disk
.into_iter()
.filter(|m| !live.is_some_and(|l| l.contains(&m.run_id)))
.map(|m| OfflineRun {
abandoned: runstate::looks_abandoned(&m, live, now),
run_id: m.run_id,
status: m.status,
error: m.error,
started_at: m.started_at,
updated_at: m.updated_at,
last_progress_at: m.last_progress_at,
empty_output: m.flags.empty_output,
})
.collect()
}
fn offline_status_cell(run: &OfflineRun) -> String {
let status = run.status.to_string().to_lowercase();
if run.abandoned {
return format!("{status} (abandoned)");
}
match run.empty_output {
true => format!("{status} (no output)"),
false => status,
}
}
pub fn format_offline(runs: &[OfflineRun], now: i64) -> Option<String> {
if runs.is_empty() {
return None;
}
let shown = runs.len().min(OFFLINE_TABLE_LIMIT);
let headers = ["RUN", "STATUS", "LAST MOVED"];
let rows: Vec<[String; 3]> = runs[..shown]
.iter()
.map(|r| {
[
r.run_id.clone(),
offline_status_cell(r),
humanize_age(now.saturating_sub(r.last_progress_at.unwrap_or(r.updated_at))),
]
})
.collect();
let mut widths = headers.map(str::len);
for row in &rows {
for (w, cell) in widths.iter_mut().zip(row) {
*w = (*w).max(cell.chars().count());
}
}
let render = |cells: &[String; 3]| {
let mut line = String::new();
for (i, (cell, width)) in cells.iter().zip(widths).enumerate() {
if i > 0 {
line.push_str(" ");
}
match i == cells.len() - 1 {
true => line.push_str(cell),
false => line.push_str(&format!("{cell:<width$}")),
}
}
line
};
let header_row = headers.map(str::to_string);
let mut out = std::iter::once("NOT RUNNING".to_string())
.chain(std::iter::once(render(&header_row)))
.chain(rows.iter().map(render))
.collect::<Vec<_>>()
.join("\n");
if runs.len() > shown {
out.push_str(&format!("\n+{} older", runs.len() - shown));
}
Some(out)
}
fn status_cell(entry: &RunListEntry) -> String {
match (&entry.status, &entry.wait_reason) {
(AgentStatus::Waiting, Some(reason)) => format!("waiting: {reason}"),
(status, _) if entry.empty_output => format!("{status} (no output)"),
(status, _) => status.to_string(),
}
}
fn humanize_age(seconds: i64) -> String {
let s = seconds.max(0);
if s < 60 {
format!("{s}s")
} else if s < 3600 {
format!("{}m", s / 60)
} else if s < 86_400 {
format!("{}h", s / 3600)
} else {
format!("{}d", s / 86_400)
}
}
fn age_cell(entry: &RunListEntry, now: i64) -> String {
match entry.last_progress_at {
Some(at) => humanize_age(now.saturating_sub(at)),
None => "-".to_string(),
}
}
fn stage_cell(entry: &RunListEntry) -> String {
match (entry.stage_index, entry.num_stages) {
(Some(i), Some(n)) if n > 1 => format!("{} {}/{}", entry.stage, i + 1, n),
_ => entry.stage.clone(),
}
}
fn providers_footer(health: &DaemonHealth) -> Option<String> {
if health.providers_down.is_empty() {
return None;
}
let each = health
.providers_down
.iter()
.map(|c| {
format!(
" {} ({}, {} failures) - retrying in {}",
c.provider,
c.reason.label(),
c.consecutive_failures,
humanize_age(c.retry_in_secs as i64)
)
})
.collect::<Vec<_>>()
.join("\n");
let noun = match health.providers_down.len() {
1 => "provider is",
_ => "providers are",
};
Some(format!(
"{} {noun} out of service:\n{each}",
health.providers_down.len()
))
}
fn reads_cell(entry: &RunListEntry) -> String {
match entry.read_paths {
Some(counts) => format!("{}/{}", counts.granted, counts.declared),
None => "-".to_string(),
}
}
fn health_footer(health: &DaemonHealth) -> Option<String> {
let saturated = health.tools_busy >= health.tools_workers && health.tools_queued > 0;
if !saturated && health.dead_cycles == 0 {
return None;
}
let mut line = format!(
"lanes: tools {}/{} busy",
health.tools_busy, health.tools_workers
);
if health.tools_parked > 0 {
line.push_str(&format!(", {} parked", health.tools_parked));
}
if health.tools_queued > 0 {
line.push_str(&format!(", {} queued", health.tools_queued));
}
if health.dead_cycles > 0 {
let seconds = health.dead_cycles as i64 * health.redrive_secs as i64;
line.push_str(&format!(
" · no progress for {} cycles ({})",
health.dead_cycles,
humanize_age(seconds)
));
}
Some(line)
}
pub fn format_runs(
runs: &[RunListEntry],
finished: &[RunListEntry],
health: &DaemonHealth,
now: i64,
) -> String {
if runs.is_empty() && finished.is_empty() {
return match providers_footer(health) {
Some(footer) => format!("no agents running\n\n{footer}"),
None => "no agents running".to_string(),
};
}
let show_reads = runs.iter().chain(finished).any(|e| e.read_paths.is_some());
let mut headers = vec!["RUN", "STATUS", "STAGE", "ITER", "TOOLS", "AGE"];
if show_reads {
headers.push("READS");
}
let rows: Vec<Vec<String>> = runs
.iter()
.chain(finished)
.map(|e| {
let mut cells = vec![
e.run_id.clone(),
status_cell(e),
stage_cell(e),
e.iteration.to_string(),
e.tool_calls.to_string(),
age_cell(e, now),
];
if show_reads {
cells.push(reads_cell(e));
}
cells
})
.collect();
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in &rows {
for (w, cell) in widths.iter_mut().zip(row) {
*w = (*w).max(cell.chars().count());
}
}
let render = |cells: &Vec<String>| {
let mut line = String::new();
for (i, (cell, width)) in cells.iter().zip(&widths).enumerate() {
if i > 0 {
line.push_str(" ");
}
match i == cells.len() - 1 {
true => line.push_str(cell),
false => line.push_str(&format!("{cell:<width$}")),
}
}
line
};
let header_row: Vec<String> = headers.iter().map(|h| (*h).to_string()).collect();
let table = std::iter::once(render(&header_row))
.chain(rows.iter().map(render))
.collect::<Vec<_>>()
.join("\n");
let blocked = runs
.iter()
.filter(|e| e.wait_reason.as_ref().is_some_and(|r| r.needs_a_person()))
.count();
let mut out = match blocked {
0 => table,
1 => format!("{table}\n\n1 run needs an answer: lev respond"),
n => format!("{table}\n\n{n} runs need an answer: lev respond"),
};
if let Some(footer) = providers_footer(health) {
out.push_str(&format!("\n\n{footer}"));
}
if let Some(footer) = health_footer(health) {
out.push_str(&format!("\n\n{footer}"));
}
out
}
fn print_listing(
runs: &[RunListEntry],
finished: &[RunListEntry],
health: &DaemonHealth,
offline: Option<&[OfflineRun]>,
daemon_reachable: bool,
args: &PsArgs,
now: i64,
) {
if args.json {
let mut body = serde_json::json!({ "runs": runs, "finished": finished, "health": health });
if let Some(offline) = offline {
body["daemon_reachable"] = serde_json::json!(daemon_reachable);
body["not_running"] = serde_json::json!(offline);
}
println!(
"{}",
serde_json::to_string_pretty(&body).expect("a run listing serializes")
);
return;
}
if daemon_reachable {
println!("{}", format_runs(runs, finished, health, now));
} else {
println!("the leviath daemon is not reachable; showing the runs dir only");
}
if let Some(block) = offline.and_then(|o| format_offline(o, now)) {
println!("\n{block}");
}
}
pub async fn send_list(client: &ControlClient, args: &PsArgs) -> anyhow::Result<()> {
let now = chrono::Utc::now().timestamp();
match (client.list().await, args.all) {
(
Ok(ControlResponse::List {
runs,
finished,
health,
}),
all,
) => {
let shown: std::collections::HashSet<String> = runs
.iter()
.chain(finished.iter())
.map(|r| r.run_id.clone())
.collect();
let offline = all.then(|| offline_runs(runstate::list_runs(), Some(&shown), now));
print_listing(
&runs,
&finished,
&health,
offline.as_deref(),
true,
args,
now,
);
Ok(())
}
(Ok(other), _) => bail!("unexpected daemon response: {other:?}"),
(Err(_), true) => {
let offline = offline_runs(runstate::list_runs(), None, now);
print_listing(
&[],
&[],
&DaemonHealth::default(),
Some(&offline),
false,
args,
now,
);
Ok(())
}
(Err(e), false) => {
bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`")
}
}
}
#[cfg(test)]
mod tests;