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. A row's
84\"has_final_output\" says whether the agent handed something back; read the
85answer itself with `lev result <run-id>` (it can be large, so it is not
86inlined here).
87
88--all adds a NOT RUNNING block, read from the runs dir rather than the daemon's
89memory. The retention window above covers the minutes after a run ends; this
90covers the rest of time, and survives a daemon restart. A row marked
91`(abandoned)` claims on disk to be running, is not held by the daemon, and has
92not moved in five minutes - clear it with `lev cancel --force <run-id>`.
93
94With --all the daemon being down is reported rather than fatal, and nothing is
95marked abandoned in that case, because an unreachable daemon looks exactly like
96every run dying at once. --all --json adds \"daemon_reachable\" and
97\"not_running\"; without --all the JSON is unchanged. Reading the runs dir costs
98a file per run and nothing prunes it, so poll --all less often than plain ps.";
99
100#[derive(clap::Args, Debug, Clone, Default)]
102pub struct PsArgs {
103 #[arg(long)]
105 pub json: bool,
106 #[arg(long)]
109 pub all: bool,
110}
111
112const OFFLINE_TABLE_LIMIT: usize = 20;
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct OfflineRun {
129 pub run_id: String,
131 pub status: RunStatus,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub error: Option<String>,
136 pub started_at: i64,
138 pub updated_at: i64,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub last_progress_at: Option<i64>,
143 #[serde(default)]
145 pub empty_output: bool,
146 #[serde(default)]
149 pub has_final_output: bool,
150 pub abandoned: bool,
156}
157
158pub fn offline_runs(
163 on_disk: Vec<RunMeta>,
164 live: Option<&std::collections::HashSet<String>>,
165 now: i64,
166) -> Vec<OfflineRun> {
167 on_disk
168 .into_iter()
169 .filter(|m| !live.is_some_and(|l| l.contains(&m.run_id)))
170 .map(|m| OfflineRun {
171 abandoned: runstate::looks_abandoned(&m, live, now),
172 run_id: m.run_id,
173 status: m.status,
174 error: m.error,
175 started_at: m.started_at,
176 updated_at: m.updated_at,
177 last_progress_at: m.last_progress_at,
178 empty_output: m.flags.empty_output,
179 has_final_output: m.final_output.is_some(),
180 })
181 .collect()
182}
183
184fn offline_status_cell(run: &OfflineRun) -> String {
187 let status = run.status.to_string().to_lowercase();
188 if run.abandoned {
189 return format!("{status} (abandoned)");
190 }
191 match run.empty_output {
192 true => format!("{status} (no output)"),
193 false => status,
194 }
195}
196
197pub fn format_offline(runs: &[OfflineRun], now: i64) -> Option<String> {
199 if runs.is_empty() {
200 return None;
201 }
202 let shown = runs.len().min(OFFLINE_TABLE_LIMIT);
203 let headers = ["RUN", "STATUS", "LAST MOVED"];
204 let rows: Vec<[String; 3]> = runs[..shown]
205 .iter()
206 .map(|r| {
207 [
208 r.run_id.clone(),
209 offline_status_cell(r),
210 humanize_age(now.saturating_sub(r.last_progress_at.unwrap_or(r.updated_at))),
211 ]
212 })
213 .collect();
214
215 let mut widths = headers.map(str::len);
216 for row in &rows {
217 for (w, cell) in widths.iter_mut().zip(row) {
218 *w = (*w).max(cell.chars().count());
219 }
220 }
221 let render = |cells: &[String; 3]| {
222 let mut line = String::new();
223 for (i, (cell, width)) in cells.iter().zip(widths).enumerate() {
224 if i > 0 {
225 line.push_str(" ");
226 }
227 match i == cells.len() - 1 {
228 true => line.push_str(cell),
229 false => line.push_str(&format!("{cell:<width$}")),
230 }
231 }
232 line
233 };
234
235 let header_row = headers.map(str::to_string);
236 let mut out = std::iter::once("NOT RUNNING".to_string())
237 .chain(std::iter::once(render(&header_row)))
238 .chain(rows.iter().map(render))
239 .collect::<Vec<_>>()
240 .join("\n");
241 if runs.len() > shown {
242 out.push_str(&format!("\n+{} older", runs.len() - shown));
243 }
244 Some(out)
245}
246
247fn status_cell(entry: &RunListEntry) -> String {
255 match (&entry.status, &entry.wait_reason) {
256 (AgentStatus::Waiting, Some(reason)) => format!("waiting: {reason}"),
257 (status, _) if entry.empty_output => format!("{status} (no output)"),
258 (status, _) => status.to_string(),
259 }
260}
261
262fn humanize_age(seconds: i64) -> String {
265 let s = seconds.max(0);
266 if s < 60 {
267 format!("{s}s")
268 } else if s < 3600 {
269 format!("{}m", s / 60)
270 } else if s < 86_400 {
271 format!("{}h", s / 3600)
272 } else {
273 format!("{}d", s / 86_400)
274 }
275}
276
277fn age_cell(entry: &RunListEntry, now: i64) -> String {
280 match entry.last_progress_at {
281 Some(at) => humanize_age(now.saturating_sub(at)),
282 None => "-".to_string(),
283 }
284}
285
286fn stage_cell(entry: &RunListEntry) -> String {
289 match (entry.stage_index, entry.num_stages) {
290 (Some(i), Some(n)) if n > 1 => format!("{} {}/{}", entry.stage, i + 1, n),
291 _ => entry.stage.clone(),
292 }
293}
294
295fn providers_footer(health: &DaemonHealth) -> Option<String> {
302 if health.providers_down.is_empty() {
303 return None;
304 }
305 let each = health
306 .providers_down
307 .iter()
308 .map(|c| {
309 format!(
310 " {} ({}, {} failures) - retrying in {}",
311 c.provider,
312 c.reason.label(),
313 c.consecutive_failures,
314 humanize_age(c.retry_in_secs as i64)
315 )
316 })
317 .collect::<Vec<_>>()
318 .join("\n");
319 let noun = match health.providers_down.len() {
320 1 => "provider is",
321 _ => "providers are",
322 };
323 Some(format!(
324 "{} {noun} out of service:\n{each}",
325 health.providers_down.len()
326 ))
327}
328
329fn reads_cell(entry: &RunListEntry) -> String {
336 match entry.read_paths {
337 Some(counts) => format!("{}/{}", counts.granted, counts.declared),
338 None => "-".to_string(),
339 }
340}
341
342fn health_footer(health: &DaemonHealth) -> Option<String> {
350 let saturated = health.tools_busy >= health.tools_workers && health.tools_queued > 0;
351 if !saturated && health.dead_cycles == 0 {
352 return None;
353 }
354 let mut line = format!(
355 "lanes: tools {}/{} busy",
356 health.tools_busy, health.tools_workers
357 );
358 if health.tools_parked > 0 {
359 line.push_str(&format!(", {} parked", health.tools_parked));
360 }
361 if health.tools_queued > 0 {
362 line.push_str(&format!(", {} queued", health.tools_queued));
363 }
364 if health.dead_cycles > 0 {
365 let seconds = health.dead_cycles as i64 * health.redrive_secs as i64;
366 line.push_str(&format!(
367 " ยท no progress for {} cycles ({})",
368 health.dead_cycles,
369 humanize_age(seconds)
370 ));
371 }
372 Some(line)
373}
374
375pub fn format_runs(
387 runs: &[RunListEntry],
388 finished: &[RunListEntry],
389 health: &DaemonHealth,
390 now: i64,
391) -> String {
392 if runs.is_empty() && finished.is_empty() {
393 return match providers_footer(health) {
399 Some(footer) => format!("no agents running\n\n{footer}"),
400 None => "no agents running".to_string(),
401 };
402 }
403 let show_reads = runs.iter().chain(finished).any(|e| e.read_paths.is_some());
407 let mut headers = vec!["RUN", "STATUS", "STAGE", "ITER", "TOOLS", "AGE"];
408 if show_reads {
409 headers.push("READS");
410 }
411 let rows: Vec<Vec<String>> = runs
412 .iter()
413 .chain(finished)
414 .map(|e| {
415 let mut cells = vec![
416 e.run_id.clone(),
417 status_cell(e),
418 stage_cell(e),
419 e.iteration.to_string(),
420 e.tool_calls.to_string(),
421 age_cell(e, now),
422 ];
423 if show_reads {
424 cells.push(reads_cell(e));
425 }
426 cells
427 })
428 .collect();
429
430 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
433 for row in &rows {
434 for (w, cell) in widths.iter_mut().zip(row) {
435 *w = (*w).max(cell.chars().count());
436 }
437 }
438
439 let render = |cells: &Vec<String>| {
440 let mut line = String::new();
441 for (i, (cell, width)) in cells.iter().zip(&widths).enumerate() {
442 if i > 0 {
443 line.push_str(" ");
444 }
445 match i == cells.len() - 1 {
447 true => line.push_str(cell),
448 false => line.push_str(&format!("{cell:<width$}")),
449 }
450 }
451 line
452 };
453
454 let header_row: Vec<String> = headers.iter().map(|h| (*h).to_string()).collect();
455 let table = std::iter::once(render(&header_row))
456 .chain(rows.iter().map(render))
457 .collect::<Vec<_>>()
458 .join("\n");
459
460 let blocked = runs
464 .iter()
465 .filter(|e| e.wait_reason.as_ref().is_some_and(|r| r.needs_a_person()))
466 .count();
467 let mut out = match blocked {
468 0 => table,
469 1 => format!("{table}\n\n1 run needs an answer: lev respond"),
470 n => format!("{table}\n\n{n} runs need an answer: lev respond"),
471 };
472 if let Some(footer) = providers_footer(health) {
473 out.push_str(&format!("\n\n{footer}"));
474 }
475 if let Some(footer) = health_footer(health) {
476 out.push_str(&format!("\n\n{footer}"));
477 }
478 out
479}
480
481fn print_listing(
485 runs: &[RunListEntry],
486 finished: &[RunListEntry],
487 health: &DaemonHealth,
488 offline: Option<&[OfflineRun]>,
489 daemon_reachable: bool,
490 args: &PsArgs,
491 now: i64,
492) {
493 if args.json {
494 let mut body = serde_json::json!({ "runs": runs, "finished": finished, "health": health });
495 if let Some(offline) = offline {
496 body["daemon_reachable"] = serde_json::json!(daemon_reachable);
499 body["not_running"] = serde_json::json!(offline);
500 }
501 println!(
503 "{}",
504 serde_json::to_string_pretty(&body).expect("a run listing serializes")
505 );
506 return;
507 }
508 if daemon_reachable {
509 println!("{}", format_runs(runs, finished, health, now));
510 } else {
511 println!("the leviath daemon is not reachable; showing the runs dir only");
512 }
513 if let Some(block) = offline.and_then(|o| format_offline(o, now)) {
514 println!("\n{block}");
515 }
516}
517
518pub async fn send_list(client: &ControlClient, args: &PsArgs) -> anyhow::Result<()> {
527 let now = chrono::Utc::now().timestamp();
528 match (client.list().await, args.all) {
529 (
530 Ok(ControlResponse::List {
531 runs,
532 finished,
533 health,
534 }),
535 all,
536 ) => {
537 let shown: std::collections::HashSet<String> = runs
542 .iter()
543 .chain(finished.iter())
544 .map(|r| r.run_id.clone())
545 .collect();
546 let offline = all.then(|| offline_runs(runstate::list_runs(), Some(&shown), now));
547 print_listing(
548 &runs,
549 &finished,
550 &health,
551 offline.as_deref(),
552 true,
553 args,
554 now,
555 );
556 Ok(())
557 }
558 (Ok(other), _) => bail!("unexpected daemon response: {other:?}"),
559 (Err(_), true) => {
560 let offline = offline_runs(runstate::list_runs(), None, now);
561 print_listing(
562 &[],
563 &[],
564 &DaemonHealth::default(),
565 Some(&offline),
566 false,
567 args,
568 now,
569 );
570 Ok(())
571 }
572 (Err(e), false) => {
573 bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`")
574 }
575 }
576}
577
578#[cfg(test)]
579mod tests;