leviath_cli/held_checkpoints.rs
1//! What still stops a `--yolo` run for a person.
2//!
3//! `--yolo` means "run without me", so a run that stops anyway looks like a
4//! hang. It is not: a blueprint can declare that a particular checkpoint needs a
5//! person however the run was launched, and the flagship `coder`
6//! does exactly that for its plan approval, because everything after that gate
7//! writes code.
8//!
9//! Two mechanisms say it, and they answer different questions, so they are not
10//! merged. `unattended = "ask"` on an interaction point is a checkpoint the
11//! framework *always* raises at a stage boundary. `required_tools` keeps a
12//! blocking tool the model *may choose* to call. A verification agent that needs
13//! "here is a fact, is it right?" to be guaranteed uses the first; one happy to
14//! let the model decide when to ask uses the second.
15//!
16//! This module reports both, so the wait is announced before the run starts
17//! rather than discovered twenty minutes later.
18
19use leviath_core::Blueprint;
20use leviath_core::blueprint::{StageMode, UnattendedPolicy};
21
22/// One thing in a blueprint that will still stop a `--yolo` run.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Held {
25 /// The stage it belongs to.
26 pub stage: String,
27 /// The interaction point's name, or the tool's.
28 pub name: String,
29}
30
31/// Every interaction point declaring `unattended = "ask"`, in stage order.
32pub fn held_points(blueprint: &Blueprint) -> Vec<Held> {
33 blueprint
34 .stages
35 .iter()
36 .flat_map(|stage| {
37 let points = match &stage.mode {
38 StageMode::InteractivePoints { points } => points.as_slice(),
39 _ => &[],
40 };
41 points
42 .iter()
43 .filter(|p| p.unattended == UnattendedPolicy::Ask)
44 .map(|p| Held {
45 stage: stage.name.clone(),
46 name: p.name.clone(),
47 })
48 })
49 .collect()
50}
51
52/// Every blocking human tool a stage keeps through `required_tools`.
53///
54/// Canonicalised, because the runtime matches on the name the model calls and a
55/// manifest may write either spelling.
56pub fn held_tools(blueprint: &Blueprint) -> Vec<Held> {
57 blueprint
58 .stages
59 .iter()
60 .flat_map(|stage| {
61 stage
62 .required_tools
63 .iter()
64 .filter(|t| {
65 leviath_runtime::dynamic_interaction::BLOCKING_INTERACTION_TOOLS
66 .contains(&leviath_tools::canonical_tool_name(t))
67 })
68 .map(|tool| Held {
69 stage: stage.name.clone(),
70 name: tool.clone(),
71 })
72 })
73 .collect()
74}
75
76/// Render `secs` as the operator would write it, so the wait is a duration
77/// rather than a number to divide.
78fn human_timeout(secs: u64) -> String {
79 match secs {
80 0 => "indefinitely".to_string(),
81 s if s % 3600 == 0 => format!("{}h", s / 3600),
82 s if s % 60 == 0 => format!("{}m", s / 60),
83 s => format!("{s}s"),
84 }
85}
86
87/// The stderr block for a `--yolo` spawn. Empty when nothing holds.
88///
89/// Pure, so the wording is testable without a daemon or a manifest on disk.
90pub fn preflight_lines(blueprint: &Blueprint, timeout_secs: u64) -> Vec<String> {
91 let points = held_points(blueprint);
92 let tools = held_tools(blueprint);
93 if points.is_empty() && tools.is_empty() {
94 return Vec::new();
95 }
96
97 let mut lines = Vec::new();
98 let total = points.len() + tools.len();
99 let plural = if total == 1 { "" } else { "s" };
100 lines.push(format!(
101 "--yolo will still stop for a person at {total} checkpoint{plural}:"
102 ));
103 for p in &points {
104 lines.push(format!(" {}: {}", p.stage, p.name));
105 }
106 for t in &tools {
107 lines.push(format!(" {}: {} (if the model calls it)", t.stage, t.name));
108 }
109 lines.push(match timeout_secs {
110 0 => " nothing expires these; the run waits until somebody answers".to_string(),
111 secs => format!(
112 " unanswered after {}, the run stops with an error; `lev respond` lists them",
113 human_timeout(secs)
114 ),
115 });
116 lines
117}
118
119#[cfg(test)]
120mod tests;