Skip to main content

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;