1use 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
16pub 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#[derive(clap::Args, Debug, Clone, Default)]
99pub struct PsArgs {
100 #[arg(long)]
102 pub json: bool,
103 #[arg(long)]
106 pub all: bool,
107}
108
109const OFFLINE_TABLE_LIMIT: usize = 20;
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct OfflineRun {
126 pub run_id: String,
128 pub status: RunStatus,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub error: Option<String>,
133 pub started_at: i64,
135 pub updated_at: i64,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub last_progress_at: Option<i64>,
140 #[serde(default)]
142 pub empty_output: bool,
143 pub abandoned: bool,
149}
150
151pub 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
176fn 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
189pub 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
239fn 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
254fn 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
269fn 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
278fn 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
287fn 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
321fn 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
334fn 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
367pub 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 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 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 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 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 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
473fn 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 body["daemon_reachable"] = serde_json::json!(daemon_reachable);
491 body["not_running"] = serde_json::json!(offline);
492 }
493 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
510pub 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 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;