leviath_runtime/pipeline/
stall.rs1use super::*;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum StallReason {
12 ProviderMissing,
17 PoolFull,
21 ProviderCircuitOpen,
30}
31
32impl StallReason {
33 pub(crate) fn label(self) -> &'static str {
35 match self {
36 StallReason::ProviderMissing => "provider-missing",
37 StallReason::PoolFull => "pool-full",
38 StallReason::ProviderCircuitOpen => "provider-circuit-open",
39 }
40 }
41
42 fn needs_a_person(self) -> bool {
48 match self {
49 StallReason::ProviderMissing | StallReason::ProviderCircuitOpen => true,
50 StallReason::PoolFull => false,
51 }
52 }
53
54 fn give_up_message(self, provider: &str) -> String {
56 match self {
57 StallReason::ProviderCircuitOpen => format!(
58 "every provider this stage can use is out of service (last was \
59 '{provider}'), so this run has nowhere to go; check the account's \
60 credits and API key, or add another provider to \
61 `[providers] fallback_order`"
62 ),
63 _ => format!(
66 "provider '{provider}' is not configured, so this run has no way to \
67 go on; add it to config.toml (or run `lev setup`) and restart the daemon"
68 ),
69 }
70 }
71}
72
73#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
79pub struct DispatchStall {
80 pub since: i64,
83 pub last_seen: i64,
91 pub reason: StallReason,
93}
94
95pub(crate) const STALL_FRESHNESS_SECS: i64 = 120;
103
104#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
109pub struct StallTimeout(pub u64);
110
111impl Default for StallTimeout {
112 fn default() -> Self {
113 Self(DEFAULT_STALL_TIMEOUT_SECS)
114 }
115}
116
117pub const DEFAULT_STALL_TIMEOUT_SECS: u64 = 60;
124
125pub(crate) fn note_stall(
134 existing: Option<&DispatchStall>,
135 reason: StallReason,
136 now: i64,
137) -> DispatchStall {
138 let since = match existing {
139 Some(prev)
140 if prev.reason == reason
141 && now.saturating_sub(prev.last_seen) <= STALL_FRESHNESS_SECS =>
142 {
143 prev.since
144 }
145 _ => now,
146 };
147 DispatchStall {
148 since,
149 last_seen: now,
150 reason,
151 }
152}
153
154#[allow(clippy::type_complexity)]
174pub fn fail_stalled_dispatch(
175 mut agents: Query<(
176 Entity,
177 &DispatchStall,
178 &StageInference,
179 &mut AgentState,
180 Option<&mut StageIoBuffer>,
181 )>,
182 timeout: Option<Res<StallTimeout>>,
183 mut commands: Commands,
184) {
185 crate::tick_scope::clear();
186 let limit = timeout.map(|t| t.0).unwrap_or(DEFAULT_STALL_TIMEOUT_SECS);
187 if limit == 0 {
188 return; }
190 let now = chrono::Utc::now().timestamp();
191 for (entity, stall, si, mut state, buffer) in agents.iter_mut() {
192 crate::tick_scope::enter(entity);
193 if state.status != AgentStatus::Active || !stall.reason.needs_a_person() {
194 continue;
195 }
196 if now.saturating_sub(stall.last_seen) > STALL_FRESHNESS_SECS {
197 tracing::debug!(
199 reason = stall.reason.label(),
200 "discarding a dispatch stall that stopped being refreshed"
201 );
202 commands.entity(entity).remove::<DispatchStall>();
203 continue;
204 }
205 if now.saturating_sub(stall.since) < limit as i64 {
206 continue; }
208 let message = stall.reason.give_up_message(&si.provider_name);
209 tracing::error!(
210 provider = %si.provider_name,
211 reason = stall.reason.label(),
212 stalled_secs = now.saturating_sub(stall.since),
213 "failing a run whose provider will never resolve"
214 );
215 if let Some(mut buffer) = buffer {
216 buffer.logs.push((0, format!("[stalled] {message}")));
217 }
218 state.status = AgentStatus::Error { message };
219 commands
220 .entity(entity)
221 .remove::<ReadyToInfer>()
222 .remove::<DispatchStall>();
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn agent_state() -> AgentState {
231 AgentState {
232 agent_id: "a".to_string(),
233 current_stage: "s".to_string(),
234 iteration: 0,
235 status: AgentStatus::Active,
236 spawned_children_ids: vec![],
237 pending_wait: None,
238 accepts_messages: true,
239 }
240 }
241
242 fn stage_inference() -> StageInference {
243 StageInference {
244 provider_name: "ghost".to_string(),
245 model: "m".to_string(),
246 tools: vec![],
247 tool_filter: None,
248 fallbacks: Vec::new(),
249 }
250 }
251
252 fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
254 let now = chrono::Utc::now().timestamp();
255 DispatchStall {
256 since: now - age,
257 last_seen: now,
258 reason,
259 }
260 }
261
262 fn spawn_stalled(world: &mut World, reason: StallReason, age: i64) -> Entity {
264 world
265 .spawn((
266 agent_state(),
267 stage_inference(),
268 stalled_for(reason, age),
269 StageIoBuffer::default(),
270 ReadyToInfer,
271 ))
272 .id()
273 }
274
275 fn run(world: &mut World) {
276 let mut schedule = Schedule::default();
277 schedule.add_systems(fail_stalled_dispatch);
278 schedule.run(world);
279 }
280
281 #[test]
282 fn a_provider_that_will_never_resolve_fails_the_run() {
283 let mut world = World::new();
284 world.insert_resource(StallTimeout(60));
285 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
286
287 run(&mut world);
288
289 let status = &world.get::<AgentState>(e).unwrap().status;
290 assert!(
291 matches!(status, AgentStatus::Error { message }
292 if message.contains("ghost") && message.contains("not configured")),
293 "got: {status:?}"
294 );
295 assert!(world.get::<ReadyToInfer>(e).is_none());
297 assert!(world.get::<DispatchStall>(e).is_none());
298 let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
300 assert!(
301 logs.iter().any(|(_, line)| line.starts_with("[stalled]")),
302 "expected a [stalled] log line, got: {logs:?}"
303 );
304 }
305
306 #[test]
307 fn a_stall_inside_the_grace_period_is_left_alone() {
308 let mut world = World::new();
309 world.insert_resource(StallTimeout(60));
310 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 59);
311
312 run(&mut world);
313
314 assert_eq!(
315 world.get::<AgentState>(e).unwrap().status,
316 AgentStatus::Active
317 );
318 assert!(world.get::<ReadyToInfer>(e).is_some());
319 }
320
321 #[test]
322 fn a_full_pool_is_backpressure_and_is_never_failed() {
323 let mut world = World::new();
324 world.insert_resource(StallTimeout(60));
325 let e = spawn_stalled(&mut world, StallReason::PoolFull, 10_000);
327
328 run(&mut world);
329
330 assert_eq!(
331 world.get::<AgentState>(e).unwrap().status,
332 AgentStatus::Active
333 );
334 assert!(world.get::<ReadyToInfer>(e).is_some());
335 assert!(world.get::<DispatchStall>(e).is_some());
336 }
337
338 #[test]
339 fn a_zero_timeout_disables_the_watchdog() {
340 let mut world = World::new();
341 world.insert_resource(StallTimeout(0));
342 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
343
344 run(&mut world);
345
346 assert_eq!(
347 world.get::<AgentState>(e).unwrap().status,
348 AgentStatus::Active
349 );
350 }
351
352 #[test]
353 fn a_world_without_the_resource_uses_the_default_timeout() {
354 let mut world = World::new();
356 let inside = spawn_stalled(
357 &mut world,
358 StallReason::ProviderMissing,
359 DEFAULT_STALL_TIMEOUT_SECS as i64 - 1,
360 );
361 let past = spawn_stalled(
362 &mut world,
363 StallReason::ProviderMissing,
364 DEFAULT_STALL_TIMEOUT_SECS as i64 + 1,
365 );
366
367 run(&mut world);
368
369 assert_eq!(
370 world.get::<AgentState>(inside).unwrap().status,
371 AgentStatus::Active
372 );
373 let status = &world.get::<AgentState>(past).unwrap().status;
374 assert!(
375 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
376 "got: {status:?}"
377 );
378 }
379
380 #[test]
381 fn a_non_active_agent_is_left_to_its_own_status() {
382 let mut world = World::new();
385 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
386 world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Paused;
387
388 run(&mut world);
389
390 assert_eq!(
391 world.get::<AgentState>(e).unwrap().status,
392 AgentStatus::Paused
393 );
394 }
395
396 #[test]
397 fn an_agent_without_a_stage_log_still_fails() {
398 let mut world = World::new();
400 let e = world
401 .spawn((
402 agent_state(),
403 stage_inference(),
404 stalled_for(StallReason::ProviderMissing, 10_000),
405 ReadyToInfer,
406 ))
407 .id();
408
409 run(&mut world);
410
411 let status = &world.get::<AgentState>(e).unwrap().status;
412 assert!(
413 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
414 "got: {status:?}"
415 );
416 }
417
418 #[test]
419 fn a_stall_that_stopped_being_refreshed_is_discarded() {
420 let mut world = World::new();
424 let now = chrono::Utc::now().timestamp();
425 let e = world
426 .spawn((
427 agent_state(),
428 stage_inference(),
429 DispatchStall {
430 since: now - 10_000,
431 last_seen: now - STALL_FRESHNESS_SECS - 1,
432 reason: StallReason::ProviderMissing,
433 },
434 ReadyToInfer,
435 ))
436 .id();
437
438 run(&mut world);
439
440 assert_eq!(
441 world.get::<AgentState>(e).unwrap().status,
442 AgentStatus::Active
443 );
444 assert!(
445 world.get::<DispatchStall>(e).is_none(),
446 "the spent record is cleared rather than left to mislead"
447 );
448 }
449
450 #[test]
451 fn note_stall_continues_a_live_stall_and_restarts_otherwise() {
452 let first = note_stall(None, StallReason::PoolFull, 100);
454 assert_eq!((first.since, first.last_seen), (100, 100));
455 let still = note_stall(Some(&first), StallReason::PoolFull, 120);
456 assert_eq!(still.since, 100, "an ongoing stall keeps its clock");
457 assert_eq!(still.last_seen, 120, "but records that it is still live");
458 let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
460 assert_eq!(changed.since, 120);
461 assert_eq!(changed.reason, StallReason::ProviderMissing);
462 let resumed = note_stall(
464 Some(&first),
465 StallReason::PoolFull,
466 100 + STALL_FRESHNESS_SECS + 1,
467 );
468 assert_eq!(resumed.since, 100 + STALL_FRESHNESS_SECS + 1);
469 }
470
471 #[test]
472 fn stall_reasons_have_labels() {
473 assert_eq!(StallReason::ProviderMissing.label(), "provider-missing");
474 assert_eq!(StallReason::PoolFull.label(), "pool-full");
475 assert_eq!(
476 StallReason::ProviderCircuitOpen.label(),
477 "provider-circuit-open"
478 );
479 }
480
481 #[test]
482 fn only_the_reasons_a_person_must_fix_are_failed() {
483 assert!(StallReason::ProviderMissing.needs_a_person());
485 assert!(StallReason::ProviderCircuitOpen.needs_a_person());
486 assert!(!StallReason::PoolFull.needs_a_person());
487 }
488
489 #[test]
490 fn a_run_with_every_provider_out_of_service_is_failed_not_left_running() {
491 let mut world = World::new();
494 world.insert_resource(StallTimeout(60));
495 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
496
497 run(&mut world);
498
499 let status = &world.get::<AgentState>(e).unwrap().status;
500 assert!(
501 matches!(status, AgentStatus::Error { message }
502 if message.contains("out of service") && message.contains("fallback_order")),
503 "got: {status:?}"
504 );
505 assert!(world.get::<ReadyToInfer>(e).is_none());
506 }
507
508 #[test]
509 fn an_open_circuit_inside_the_grace_period_gets_its_chance_to_recover() {
510 let mut world = World::new();
513 world.insert_resource(StallTimeout(60));
514 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 59);
515
516 run(&mut world);
517
518 assert_eq!(
519 world.get::<AgentState>(e).unwrap().status,
520 AgentStatus::Active
521 );
522 }
523
524 #[test]
525 fn the_give_up_message_names_the_provider() {
526 let missing = StallReason::ProviderMissing.give_up_message("ghost");
527 assert!(missing.contains("ghost") && missing.contains("not configured"));
528 let open = StallReason::ProviderCircuitOpen.give_up_message("openrouter");
529 assert!(open.contains("openrouter") && open.contains("out of service"));
530 assert!(StallReason::PoolFull.give_up_message("x").contains("x"));
532 }
533
534 #[test]
535 fn the_default_timeout_is_the_documented_grace_period() {
536 assert_eq!(StallTimeout::default().0, DEFAULT_STALL_TIMEOUT_SECS);
537 }
538}