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 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#[derive(clap::Args, Debug, Clone, Default)]
106pub struct PsArgs {
107 #[arg(long)]
109 pub json: bool,
110 #[arg(long)]
113 pub all: bool,
114}
115
116const OFFLINE_TABLE_LIMIT: usize = 20;
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct OfflineRun {
133 pub run_id: String,
135 pub status: RunStatus,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub error: Option<String>,
140 pub started_at: i64,
142 pub updated_at: i64,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub last_progress_at: Option<i64>,
147 #[serde(default)]
149 pub empty_output: bool,
150 #[serde(default)]
153 pub has_final_output: bool,
154 pub abandoned: bool,
160}
161
162pub 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
188fn 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
201pub 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
251fn status_cell(entry: &RunListEntry) -> String {
259 match (&entry.status, &entry.wait_reason) {
260 (AgentStatus::Waiting, Some(reason)) => format!("waiting: {reason}"),
261 (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
270fn 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
285fn 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
294fn 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
303fn 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
337fn 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
350fn 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
383pub 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 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 let show_reads = runs.iter().chain(finished).any(|e| e.read_paths.is_some());
415 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 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 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 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
500fn 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 body["daemon_reachable"] = serde_json::json!(daemon_reachable);
518 body["not_running"] = serde_json::json!(offline);
519 }
520 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
537pub 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 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;