Skip to main content

leviath_runtime/pipeline/
watchdog.rs

1//! Watchdogs that end a run the stage graph would otherwise let spin:
2//! a workspace that disappeared, an iteration cap, and stuck detection -
3//! plus the context notes each writes so the reason survives into the run's
4//! transcript rather than only its status.
5
6use super::*;
7
8/// How often (in per-stage iterations) [`check_workspace_health`] stats the
9/// agent's working directory. One `metadata` call every few iterations is far
10/// cheaper than the tool failures it replaces.
11pub const WORKSPACE_CHECK_INTERVAL: usize = 5;
12
13/// What `check_workspace_health` selects.
14///
15/// `&'static` is bevy's `WorldQuery` convention, not a claim about
16/// lifetimes: the borrow is bound when the query is fetched.
17type WorkspaceHealthQuery = (
18    Entity,
19    &'static RunMetadata,
20    &'static StageProgress,
21    &'static mut AgentState,
22    Option<&'static mut crate::persistence::RunOutcomeFlags>,
23);
24
25/// Workspace health guard: fail a run whose working directory has disappeared.
26///
27/// The motivating failure: an external harness deleted the workspace out from
28/// under running agents, which then spent every remaining iteration collecting
29/// `No such file or directory` from their tools - 16-17 of them in the observed
30/// runs - with no way back. Nothing can recreate a deleted checkout from inside
31/// the agent, so this stops immediately with a message that names the real
32/// problem, instead of routing to error recovery to flail more cheaply.
33pub fn check_workspace_health(
34    mut agents: Query<WorkspaceHealthQuery, With<ReadyToInfer>>,
35    mut commands: Commands,
36) {
37    crate::tick_scope::clear();
38    for (entity, md, progress, mut state, flags) in agents.iter_mut() {
39        crate::tick_scope::enter(entity);
40        if state.status != AgentStatus::Active {
41            continue;
42        }
43        if progress.iterations % WORKSPACE_CHECK_INTERVAL != 0 {
44            continue;
45        }
46        if std::fs::metadata(&md.workdir).is_ok_and(|m| m.is_dir()) {
47            continue;
48        }
49        tracing::error!(
50            run_id = %md.run_id,
51            workdir = %md.workdir,
52            "working directory is gone; failing the run"
53        );
54        state.status = AgentStatus::Error {
55            message: format!("workspace '{}' is no longer accessible", md.workdir),
56        };
57        if let Some(mut flags) = flags {
58            flags.0.workspace_lost = true;
59        }
60        commands.entity(entity).remove::<ReadyToInfer>();
61    }
62}
63
64/// What `enforce_max_iterations` selects.
65///
66/// `&'static` is bevy's `WorldQuery` convention, not a claim about
67/// lifetimes: the borrow is bound when the query is fetched.
68type MaxIterationQuery = (
69    Entity,
70    &'static AgentState,
71    &'static AgentBlueprint,
72    &'static StageCursor,
73    &'static StageProgress,
74    Option<&'static mut crate::persistence::RunOutcomeFlags>,
75);
76
77/// Max-iterations guard: for each `ReadyToInfer` agent whose per-stage inference
78/// count has reached the stage's `max_iterations`, end the stage (routing to a
79/// `max_iterations` edge if one exists, else a normal transition) instead of
80/// running another inference. Ported from the imperative `run_autonomous` cap.
81pub fn enforce_max_iterations(
82    mut agents: Query<MaxIterationQuery, With<ReadyToInfer>>,
83    mut commands: Commands,
84) {
85    crate::tick_scope::clear();
86    for (entity, state, bp, cursor, progress, flags) in agents.iter_mut() {
87        crate::tick_scope::enter(entity);
88        if state.status != AgentStatus::Active {
89            continue;
90        }
91        let max = bp.0.stages[cursor.index].max_iterations.unwrap_or(0);
92        if max > 0 && progress.iterations >= max {
93            // Record it on the run: a stage that ran out of iterations is one of
94            // the ways a run ends up with nothing to show (issue #107).
95            if let Some(mut flags) = flags {
96                flags.0.max_iterations_hit += 1;
97            }
98            commands
99                .entity(entity)
100                .remove::<ReadyToInfer>()
101                .insert(ResolveTransition)
102                .insert(StageOutcome::MaxIterations);
103        }
104    }
105}
106
107/// The context region a stuck diagnosis is written to when the blueprint declares
108/// one. Pinned by convention, so the note survives the edge transform into the
109/// stage that has to act on it.
110pub(crate) const STUCK_REPORT_REGION: &str = "stuck_report";
111
112/// The context region an abnormal-ending note (inference error, iteration cap)
113/// is written to when the blueprint declares one. Pinned by convention, like
114/// [`STUCK_REPORT_REGION`], so the note survives the edge transform into the
115/// stage that has to act on it.
116pub(crate) const ERROR_REPORT_REGION: &str = "error_report";
117
118/// The per-stage numbers a [`StuckConfig`](leviath_core::blueprint::StuckConfig)
119/// is evaluated against.
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
121pub(crate) struct StuckMetrics {
122    /// Inferences run in this stage.
123    pub iterations: usize,
124    /// Wall-clock seconds since the stage clock was stamped.
125    pub elapsed_secs: u64,
126    /// Total tool calls made in this stage.
127    pub tool_calls: usize,
128    /// The most-churned path this stage and how many write/edit calls it took.
129    pub hottest_edit: Option<(String, usize)>,
130}
131
132/// Evaluate a stage's metrics against a stuck edge's thresholds, returning a
133/// human-readable reason for the first one that trips.
134///
135/// Ordered most-diagnostic first: file churn names the actual mistake, while
136/// iterations, tool calls and wall clock are only symptoms of it.
137pub(crate) fn detect_stuck(
138    cfg: &leviath_core::blueprint::StuckConfig,
139    m: &StuckMetrics,
140) -> Option<String> {
141    if let (Some(limit), Some((path, hits))) = (cfg.after_same_file_edits, m.hottest_edit.as_ref())
142        && *hits >= limit
143    {
144        return Some(format!(
145            "you have written or edited '{path}' {hits} times in this stage without \
146             resolving the task - the problem is very likely not in that file"
147        ));
148    }
149    if let Some(limit) = cfg.after_iterations
150        && m.iterations >= limit
151    {
152        return Some(format!(
153            "you have run {} inference turns in this stage without finishing it",
154            m.iterations
155        ));
156    }
157    if let Some(limit) = cfg.after_tool_calls
158        && m.tool_calls >= limit
159    {
160        return Some(format!(
161            "you have made {} tool calls in this stage without finishing it",
162            m.tool_calls
163        ));
164    }
165    if let Some(limit) = cfg.after_minutes
166        && m.elapsed_secs >= limit as u64 * 60
167    {
168        return Some(format!(
169            "you have spent {} minutes in this stage without finishing it",
170            m.elapsed_secs / 60
171        ));
172    }
173    None
174}
175
176/// The single most-edited path in a stage. Ties break on path name so the
177/// diagnosis is deterministic regardless of `HashMap` iteration order.
178pub(crate) fn hottest_edit(
179    edits: &std::collections::HashMap<String, usize>,
180) -> Option<(String, usize)> {
181    edits
182        .iter()
183        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
184        .map(|(path, n)| (path.clone(), *n))
185}
186
187/// Write the "why you're stuck" note where the next stage will read it: the
188/// blueprint's `stuck_report` region when it declares one, else `conversation`
189/// (which every blueprint is required to declare). Best-effort, like the
190/// repetition nudge - an overflowing region silently drops the note.
191pub(crate) fn note_stuck(window: &mut ContextWindow, stage: &str, reason: &str) {
192    let region = if window.get_region(STUCK_REPORT_REGION).is_some() {
193        STUCK_REPORT_REGION
194    } else {
195        "conversation"
196    };
197    let content = format!(
198        "[Stuck detected in stage '{stage}'] {reason}. Stop repeating what you have been \
199         doing. Re-read the original task, separate what you have actually verified from \
200         what you assumed, and take a different approach - including reverting changes \
201         that made things worse."
202    );
203    let tokens = leviath_core::estimate_tokens(&content);
204    let _ = window.add_to_region(region, content, tokens);
205}
206
207/// Write an abnormal-ending note where the next stage will read it: the
208/// blueprint's `error_report` region when it declares one, else `conversation`.
209/// Best-effort, like [`note_stuck`] - an overflowing region silently drops it.
210fn note_abnormal_ending(window: &mut ContextWindow, content: String) {
211    let region = if window.get_region(ERROR_REPORT_REGION).is_some() {
212        ERROR_REPORT_REGION
213    } else {
214        "conversation"
215    };
216    let tokens = leviath_core::estimate_tokens(&content);
217    let _ = window.add_to_region(region, content, tokens);
218}
219
220/// Write the inference error that ended a stage into context, so the recovery
221/// stage an `error` edge routes to starts out knowing what failed instead of
222/// being told to diagnose an error it cannot see.
223pub(crate) fn note_error(window: &mut ContextWindow, stage: &str, message: &str) {
224    note_abnormal_ending(
225        window,
226        format!(
227            "[Inference error in stage '{stage}'] {message}. Diagnose this failure from \
228             the error text above before retrying or working around it."
229        ),
230    );
231}
232
233/// Write an iteration-cap note into context when a stage runs out of
234/// iterations, so whatever stage runs next - a `max_iterations` edge target or
235/// the normal successor - knows the work was cut off rather than finished.
236pub(crate) fn note_max_iterations(window: &mut ContextWindow, stage: &str, cap: usize) {
237    note_abnormal_ending(
238        window,
239        format!(
240            "[Stage '{stage}' hit its iteration cap ({cap})] The stage was cut off before \
241             it declared completion - treat its output as possibly incomplete and verify \
242             it before building on it."
243        ),
244    );
245}
246
247/// What `detect_stuck_stage` selects.
248///
249/// `&'static` is bevy's `WorldQuery` convention, not a claim about
250/// lifetimes: the borrow is bound when the query is fetched.
251type StuckStageQuery = (
252    Entity,
253    &'static AgentState,
254    &'static AgentBlueprint,
255    &'static StageCursor,
256    &'static mut StageProgress,
257    &'static VisitCounts,
258    &'static mut ContextWindow,
259    Option<&'static mut StageIoBuffer>,
260);
261
262/// Stuck-detection guard: for each `ReadyToInfer` agent whose current stage
263/// declares a `stuck`-conditioned edge, evaluate that edge's thresholds against
264/// the stage's progress. When one trips, write the diagnosis into context and
265/// route the agent down the stuck edge (`ResolveTransition` +
266/// [`StageOutcome::Stuck`]) instead of running another inference.
267///
268/// Fires at most once per stage entry (`StageProgress::stuck_fired`, cleared by
269/// `enter_stage`'s progress reset), and never once the stuck edge's target has
270/// spent its `max_revisits` - an exhausted escape hatch must leave the agent
271/// working the stage normally (its `max_iterations` is still the hard cap) rather
272/// than kick it out down an unrelated edge.
273pub fn detect_stuck_stage(
274    mut agents: Query<StuckStageQuery, With<ReadyToInfer>>,
275    mut commands: Commands,
276) {
277    use leviath_core::blueprint::TransitionCondition;
278    let now = chrono::Utc::now().timestamp();
279    crate::tick_scope::clear();
280    for (entity, state, bp, cursor, mut progress, visits, mut window, buffer) in agents.iter_mut() {
281        crate::tick_scope::enter(entity);
282        if state.status != AgentStatus::Active || progress.stuck_fired {
283            continue; // paused/waiting, or this stage already used its escape
284        }
285        let stage = &bp.0.stages[cursor.index];
286        let Some(cfg) =
287            find_conditioned_edge_ref(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
288                .and_then(|(_, edge)| edge.stuck)
289        else {
290            continue; // no stuck edge here, or its escape hatch is spent
291        };
292        // Lazy stamp: one place covers spawn, `enter_stage`, `force_transition`
293        // and snapshot restore, and it measures time the agent was actually
294        // runnable rather than time spent queued behind other work.
295        let started = *progress.stage_started_at.get_or_insert(now);
296        let metrics = StuckMetrics {
297            iterations: progress.iterations,
298            elapsed_secs: (now - started).max(0) as u64,
299            tool_calls: progress.total_tool_calls,
300            hottest_edit: hottest_edit(&progress.edits_by_path),
301        };
302        let Some(reason) = detect_stuck(&cfg, &metrics) else {
303            continue;
304        };
305        progress.stuck_fired = true;
306        note_stuck(&mut window, &stage.name, &reason);
307        if let Some(mut buffer) = buffer {
308            buffer
309                .logs
310                .push((cursor.index, format!("[stuck] {reason}")));
311        }
312        commands
313            .entity(entity)
314            .remove::<ReadyToInfer>()
315            .insert(ResolveTransition)
316            .insert(StageOutcome::Stuck(reason));
317    }
318}