leviath_runtime/pipeline/
watchdog.rs1use super::*;
7
8pub const WORKSPACE_CHECK_INTERVAL: usize = 5;
12
13type WorkspaceHealthQuery = (
18 Entity,
19 &'static RunMetadata,
20 &'static StageProgress,
21 &'static mut AgentState,
22 Option<&'static mut crate::persistence::RunOutcomeFlags>,
23);
24
25pub 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
64type MaxIterationQuery = (
69 Entity,
70 &'static AgentState,
71 &'static AgentBlueprint,
72 &'static StageCursor,
73 &'static StageProgress,
74 Option<&'static mut crate::persistence::RunOutcomeFlags>,
75);
76
77pub 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 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
107pub(crate) const STUCK_REPORT_REGION: &str = "stuck_report";
111
112pub(crate) const ERROR_REPORT_REGION: &str = "error_report";
117
118#[derive(Debug, Clone, Default, PartialEq, Eq)]
121pub(crate) struct StuckMetrics {
122 pub iterations: usize,
124 pub elapsed_secs: u64,
126 pub tool_calls: usize,
128 pub hottest_edit: Option<(String, usize)>,
130}
131
132pub(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
176pub(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
187pub(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
207fn 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
220pub(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
233pub(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
247type 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
262pub 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; }
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; };
292 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}