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 (status, _) if entry.empty_output => format!("{status} (no output)"),
262 (status, _) => status.to_string(),
263 }
264}
265
266fn humanize_age(seconds: i64) -> String {
269 let s = seconds.max(0);
270 if s < 60 {
271 format!("{s}s")
272 } else if s < 3600 {
273 format!("{}m", s / 60)
274 } else if s < 86_400 {
275 format!("{}h", s / 3600)
276 } else {
277 format!("{}d", s / 86_400)
278 }
279}
280
281fn age_cell(entry: &RunListEntry, now: i64) -> String {
284 match entry.last_progress_at {
285 Some(at) => humanize_age(now.saturating_sub(at)),
286 None => "-".to_string(),
287 }
288}
289
290fn stage_cell(entry: &RunListEntry) -> String {
293 match (entry.stage_index, entry.num_stages) {
294 (Some(i), Some(n)) if n > 1 => format!("{} {}/{}", entry.stage, i + 1, n),
295 _ => entry.stage.clone(),
296 }
297}
298
299fn providers_footer(health: &DaemonHealth) -> Option<String> {
306 if health.providers_down.is_empty() {
307 return None;
308 }
309 let each = health
310 .providers_down
311 .iter()
312 .map(|c| {
313 format!(
314 " {} ({}, {} failures) - retrying in {}",
315 c.provider,
316 c.reason.label(),
317 c.consecutive_failures,
318 humanize_age(c.retry_in_secs as i64)
319 )
320 })
321 .collect::<Vec<_>>()
322 .join("\n");
323 let noun = match health.providers_down.len() {
324 1 => "provider is",
325 _ => "providers are",
326 };
327 Some(format!(
328 "{} {noun} out of service:\n{each}",
329 health.providers_down.len()
330 ))
331}
332
333fn reads_cell(entry: &RunListEntry) -> String {
340 match entry.read_paths {
341 Some(counts) => format!("{}/{}", counts.granted, counts.declared),
342 None => "-".to_string(),
343 }
344}
345
346fn health_footer(health: &DaemonHealth) -> Option<String> {
354 let saturated = health.tools_busy >= health.tools_workers && health.tools_queued > 0;
355 if !saturated && health.dead_cycles == 0 {
356 return None;
357 }
358 let mut line = format!(
359 "lanes: tools {}/{} busy",
360 health.tools_busy, health.tools_workers
361 );
362 if health.tools_parked > 0 {
363 line.push_str(&format!(", {} parked", health.tools_parked));
364 }
365 if health.tools_queued > 0 {
366 line.push_str(&format!(", {} queued", health.tools_queued));
367 }
368 if health.dead_cycles > 0 {
369 let seconds = health.dead_cycles as i64 * health.redrive_secs as i64;
370 line.push_str(&format!(
371 " ยท no progress for {} cycles ({})",
372 health.dead_cycles,
373 humanize_age(seconds)
374 ));
375 }
376 Some(line)
377}
378
379pub fn format_runs(
391 runs: &[RunListEntry],
392 finished: &[RunListEntry],
393 health: &DaemonHealth,
394 now: i64,
395) -> String {
396 if runs.is_empty() && finished.is_empty() {
397 return match providers_footer(health) {
403 Some(footer) => format!("no agent runs active\n\n{footer}"),
404 None => "no agent runs active".to_string(),
405 };
406 }
407 let show_reads = runs.iter().chain(finished).any(|e| e.read_paths.is_some());
411 let show_title = runs.iter().chain(finished).any(|e| e.title.is_some());
415 let mut headers = vec!["RUN"];
416 if show_title {
417 headers.push("TITLE");
418 }
419 headers.extend(["STATUS", "STAGE", "ITER", "TOOLS", "AGE"]);
420 if show_reads {
421 headers.push("READS");
422 }
423 let rows: Vec<Vec<String>> = runs
424 .iter()
425 .chain(finished)
426 .map(|e| {
427 let mut cells = vec![e.run_id.clone()];
428 if show_title {
429 cells.push(e.title.clone().unwrap_or_default());
430 }
431 cells.extend([
432 status_cell(e),
433 stage_cell(e),
434 e.iteration.to_string(),
435 e.tool_calls.to_string(),
436 age_cell(e, now),
437 ]);
438 if show_reads {
439 cells.push(reads_cell(e));
440 }
441 cells
442 })
443 .collect();
444
445 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
448 for row in &rows {
449 for (w, cell) in widths.iter_mut().zip(row) {
450 *w = (*w).max(cell.chars().count());
451 }
452 }
453
454 let render = |cells: &Vec<String>| {
455 let mut line = String::new();
456 for (i, (cell, width)) in cells.iter().zip(&widths).enumerate() {
457 if i > 0 {
458 line.push_str(" ");
459 }
460 match i == cells.len() - 1 {
462 true => line.push_str(cell),
463 false => line.push_str(&format!("{cell:<width$}")),
464 }
465 }
466 line
467 };
468
469 let header_row: Vec<String> = headers.iter().map(|h| (*h).to_string()).collect();
470 let table = std::iter::once(render(&header_row))
471 .chain(rows.iter().map(render))
472 .collect::<Vec<_>>()
473 .join("\n");
474
475 let blocked = runs
479 .iter()
480 .filter(|e| e.wait_reason.as_ref().is_some_and(|r| r.needs_a_person()))
481 .count();
482 let mut out = match blocked {
483 0 => table,
484 1 => format!("{table}\n\n1 run needs an answer: lev respond"),
485 n => format!("{table}\n\n{n} runs need an answer: lev respond"),
486 };
487 if let Some(footer) = providers_footer(health) {
488 out.push_str(&format!("\n\n{footer}"));
489 }
490 if let Some(footer) = health_footer(health) {
491 out.push_str(&format!("\n\n{footer}"));
492 }
493 out
494}
495
496fn print_listing(
500 runs: &[RunListEntry],
501 finished: &[RunListEntry],
502 health: &DaemonHealth,
503 offline: Option<&[OfflineRun]>,
504 daemon_reachable: bool,
505 args: &PsArgs,
506 now: i64,
507) {
508 if args.json {
509 let mut body = serde_json::json!({ "runs": runs, "finished": finished, "health": health });
510 if let Some(offline) = offline {
511 body["daemon_reachable"] = serde_json::json!(daemon_reachable);
514 body["not_running"] = serde_json::json!(offline);
515 }
516 println!(
518 "{}",
519 serde_json::to_string_pretty(&body).expect("a run listing serializes")
520 );
521 return;
522 }
523 if daemon_reachable {
524 println!("{}", format_runs(runs, finished, health, now));
525 } else {
526 println!("the leviath daemon is not reachable; showing the runs dir only");
527 }
528 if let Some(block) = offline.and_then(|o| format_offline(o, now)) {
529 println!("\n{block}");
530 }
531}
532
533pub async fn send_list(client: &ControlClient, args: &PsArgs) -> anyhow::Result<()> {
542 let now = chrono::Utc::now().timestamp();
543 match (client.list().await, args.all) {
544 (
545 Ok(ControlResponse::List {
546 runs,
547 finished,
548 health,
549 }),
550 all,
551 ) => {
552 let shown: std::collections::HashSet<String> = runs
557 .iter()
558 .chain(finished.iter())
559 .map(|r| r.run_id.clone())
560 .collect();
561 let offline = all.then(|| offline_runs(runstate::list_runs(), Some(&shown), now));
562 print_listing(
563 &runs,
564 &finished,
565 &health,
566 offline.as_deref(),
567 true,
568 args,
569 now,
570 );
571 Ok(())
572 }
573 (Ok(other), _) => bail!("unexpected daemon response: {other:?}"),
574 (Err(_), true) => {
575 let offline = offline_runs(runstate::list_runs(), None, now);
576 print_listing(
577 &[],
578 &[],
579 &DaemonHealth::default(),
580 Some(&offline),
581 false,
582 args,
583 now,
584 );
585 Ok(())
586 }
587 (Err(e), false) => {
588 bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`")
589 }
590 }
591}
592
593#[cfg(test)]
594mod tests;