1use 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
117#[derive(Resource, Debug, Clone, Copy)]
127pub struct StallClock(
128 pub fn() -> i64,
131);
132
133fn now_secs() -> i64 {
136 chrono::Utc::now().timestamp()
137}
138
139pub const DEFAULT_STALL_TIMEOUT_SECS: u64 = 60;
146
147pub(crate) fn note_stall(
156 existing: Option<&DispatchStall>,
157 reason: StallReason,
158 now: i64,
159) -> DispatchStall {
160 let since = match existing {
161 Some(prev)
162 if prev.reason == reason
163 && now.saturating_sub(prev.last_seen) <= STALL_FRESHNESS_SECS =>
164 {
165 prev.since
166 }
167 _ => now,
168 };
169 DispatchStall {
170 since,
171 last_seen: now,
172 reason,
173 }
174}
175
176type StalledDispatchQuery = (
181 Entity,
182 &'static DispatchStall,
183 &'static StageInference,
184 &'static mut AgentState,
185 Option<&'static mut StageIoBuffer>,
186);
187
188pub fn fail_stalled_dispatch(
208 mut agents: Query<StalledDispatchQuery>,
209 timeout: Option<Res<StallTimeout>>,
210 clock: Option<Res<StallClock>>,
211 mut commands: Commands,
212) {
213 crate::tick_scope::clear();
214 let limit = timeout.map(|t| t.0).unwrap_or(DEFAULT_STALL_TIMEOUT_SECS);
215 if limit == 0 {
216 return; }
218 let now = clock.map_or_else(now_secs, |c| (c.0)());
219 for (entity, stall, si, mut state, buffer) in agents.iter_mut() {
220 crate::tick_scope::enter(entity);
221 if state.status != AgentStatus::Active || !stall.reason.needs_a_person() {
222 continue;
223 }
224 if now.saturating_sub(stall.last_seen) > STALL_FRESHNESS_SECS {
225 tracing::debug!(
227 reason = stall.reason.label(),
228 "discarding a dispatch stall that stopped being refreshed"
229 );
230 commands.entity(entity).remove::<DispatchStall>();
231 continue;
232 }
233 if now.saturating_sub(stall.since) < limit as i64 {
234 continue; }
236 let message = stall.reason.give_up_message(&si.provider_name);
237 tracing::error!(
238 provider = %si.provider_name,
239 reason = stall.reason.label(),
240 stalled_secs = now.saturating_sub(stall.since),
241 "failing a run whose provider will never resolve"
242 );
243 if let Some(mut buffer) = buffer {
244 buffer.logs.push((0, format!("[stalled] {message}")));
245 }
246 state.status = AgentStatus::Error { message };
247 commands
248 .entity(entity)
249 .remove::<ReadyToInfer>()
250 .remove::<DispatchStall>();
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn agent_state() -> AgentState {
259 AgentState {
260 agent_id: "a".to_string(),
261 current_stage: "s".to_string(),
262 iteration: 0,
263 status: AgentStatus::Active,
264 spawned_children_ids: vec![],
265 pending_wait: None,
266 accepts_messages: true,
267 }
268 }
269
270 fn stage_inference() -> StageInference {
271 StageInference {
272 provider_name: "ghost".to_string(),
273 model: "m".to_string(),
274 tools: vec![],
275 tool_filter: None,
276 fallbacks: Vec::new(),
277 output: None,
278 }
279 }
280
281 const NOW: i64 = 1_700_000_000;
286
287 fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
289 DispatchStall {
290 since: NOW - age,
291 last_seen: NOW,
292 reason,
293 }
294 }
295
296 fn spawn_stalled(world: &mut World, reason: StallReason, age: i64) -> Entity {
298 world
299 .spawn((
300 agent_state(),
301 stage_inference(),
302 stalled_for(reason, age),
303 StageIoBuffer::default(),
304 ReadyToInfer,
305 ))
306 .id()
307 }
308
309 fn run(world: &mut World) {
312 world.insert_resource(StallClock(|| NOW));
313 run_on_the_wall_clock(world);
314 }
315
316 fn run_on_the_wall_clock(world: &mut World) {
318 let mut schedule = Schedule::default();
319 schedule.add_systems(fail_stalled_dispatch);
320 schedule.run(world);
321 }
322
323 #[test]
324 fn a_provider_that_will_never_resolve_fails_the_run() {
325 let mut world = World::new();
326 world.insert_resource(StallTimeout(60));
327 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
328
329 run(&mut world);
330
331 let status = &world.get::<AgentState>(e).unwrap().status;
332 assert!(
333 matches!(status, AgentStatus::Error { message }
334 if message.contains("ghost") && message.contains("not configured")),
335 "got: {status:?}"
336 );
337 assert!(world.get::<ReadyToInfer>(e).is_none());
339 assert!(world.get::<DispatchStall>(e).is_none());
340 let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
342 assert!(
343 logs.iter().any(|(_, line)| line.starts_with("[stalled]")),
344 "expected a [stalled] log line, got: {logs:?}"
345 );
346 }
347
348 #[test]
349 fn a_stall_inside_the_grace_period_is_left_alone() {
350 let mut world = World::new();
351 world.insert_resource(StallTimeout(60));
352 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 59);
353
354 run(&mut world);
355
356 assert_eq!(
357 world.get::<AgentState>(e).unwrap().status,
358 AgentStatus::Active
359 );
360 assert!(world.get::<ReadyToInfer>(e).is_some());
361 }
362
363 #[test]
364 fn the_grace_period_ends_the_second_it_is_reached() {
365 let mut world = World::new();
369 world.insert_resource(StallTimeout(60));
370 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 60);
371
372 run(&mut world);
373
374 let status = &world.get::<AgentState>(e).unwrap().status;
375 assert!(
376 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
377 "got: {status:?}"
378 );
379 }
380
381 #[test]
382 fn nothing_pinning_the_clock_means_the_wall_clock() {
383 let mut world = World::new();
386 world.insert_resource(StallTimeout(60));
387 let now = chrono::Utc::now().timestamp();
388 let e = world
389 .spawn((
390 agent_state(),
391 stage_inference(),
392 DispatchStall {
393 since: now - 10_000,
394 last_seen: now,
395 reason: StallReason::ProviderMissing,
396 },
397 ReadyToInfer,
398 ))
399 .id();
400
401 run_on_the_wall_clock(&mut world);
402
403 let status = &world.get::<AgentState>(e).unwrap().status;
404 assert!(
405 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
406 "got: {status:?}"
407 );
408 }
409
410 #[test]
411 fn a_full_pool_is_backpressure_and_is_never_failed() {
412 let mut world = World::new();
413 world.insert_resource(StallTimeout(60));
414 let e = spawn_stalled(&mut world, StallReason::PoolFull, 10_000);
416
417 run(&mut world);
418
419 assert_eq!(
420 world.get::<AgentState>(e).unwrap().status,
421 AgentStatus::Active
422 );
423 assert!(world.get::<ReadyToInfer>(e).is_some());
424 assert!(world.get::<DispatchStall>(e).is_some());
425 }
426
427 #[test]
428 fn a_zero_timeout_disables_the_watchdog() {
429 let mut world = World::new();
430 world.insert_resource(StallTimeout(0));
431 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
432
433 run(&mut world);
434
435 assert_eq!(
436 world.get::<AgentState>(e).unwrap().status,
437 AgentStatus::Active
438 );
439 }
440
441 #[test]
442 fn a_world_without_the_resource_uses_the_default_timeout() {
443 let mut world = World::new();
445 let inside = spawn_stalled(
446 &mut world,
447 StallReason::ProviderMissing,
448 DEFAULT_STALL_TIMEOUT_SECS as i64 - 1,
449 );
450 let past = spawn_stalled(
451 &mut world,
452 StallReason::ProviderMissing,
453 DEFAULT_STALL_TIMEOUT_SECS as i64 + 1,
454 );
455
456 run(&mut world);
457
458 assert_eq!(
459 world.get::<AgentState>(inside).unwrap().status,
460 AgentStatus::Active
461 );
462 let status = &world.get::<AgentState>(past).unwrap().status;
463 assert!(
464 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
465 "got: {status:?}"
466 );
467 }
468
469 #[test]
470 fn a_non_active_agent_is_left_to_its_own_status() {
471 let mut world = World::new();
474 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
475 world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Paused;
476
477 run(&mut world);
478
479 assert_eq!(
480 world.get::<AgentState>(e).unwrap().status,
481 AgentStatus::Paused
482 );
483 }
484
485 #[test]
486 fn an_agent_without_a_stage_log_still_fails() {
487 let mut world = World::new();
489 let e = world
490 .spawn((
491 agent_state(),
492 stage_inference(),
493 stalled_for(StallReason::ProviderMissing, 10_000),
494 ReadyToInfer,
495 ))
496 .id();
497
498 run(&mut world);
499
500 let status = &world.get::<AgentState>(e).unwrap().status;
501 assert!(
502 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
503 "got: {status:?}"
504 );
505 }
506
507 #[test]
508 fn a_stall_that_stopped_being_refreshed_is_discarded() {
509 let mut world = World::new();
513 let e = world
514 .spawn((
515 agent_state(),
516 stage_inference(),
517 DispatchStall {
518 since: NOW - 10_000,
519 last_seen: NOW - STALL_FRESHNESS_SECS - 1,
520 reason: StallReason::ProviderMissing,
521 },
522 ReadyToInfer,
523 ))
524 .id();
525
526 run(&mut world);
527
528 assert_eq!(
529 world.get::<AgentState>(e).unwrap().status,
530 AgentStatus::Active
531 );
532 assert!(
533 world.get::<DispatchStall>(e).is_none(),
534 "the spent record is cleared rather than left to mislead"
535 );
536 }
537
538 #[test]
539 fn note_stall_continues_a_live_stall_and_restarts_otherwise() {
540 let first = note_stall(None, StallReason::PoolFull, 100);
542 assert_eq!((first.since, first.last_seen), (100, 100));
543 let still = note_stall(Some(&first), StallReason::PoolFull, 120);
544 assert_eq!(still.since, 100, "an ongoing stall keeps its clock");
545 assert_eq!(still.last_seen, 120, "but records that it is still live");
546 let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
548 assert_eq!(changed.since, 120);
549 assert_eq!(changed.reason, StallReason::ProviderMissing);
550 let resumed = note_stall(
552 Some(&first),
553 StallReason::PoolFull,
554 100 + STALL_FRESHNESS_SECS + 1,
555 );
556 assert_eq!(resumed.since, 100 + STALL_FRESHNESS_SECS + 1);
557 }
558
559 #[test]
560 fn stall_reasons_have_labels() {
561 assert_eq!(StallReason::ProviderMissing.label(), "provider-missing");
562 assert_eq!(StallReason::PoolFull.label(), "pool-full");
563 assert_eq!(
564 StallReason::ProviderCircuitOpen.label(),
565 "provider-circuit-open"
566 );
567 }
568
569 #[test]
570 fn only_the_reasons_a_person_must_fix_are_failed() {
571 assert!(StallReason::ProviderMissing.needs_a_person());
573 assert!(StallReason::ProviderCircuitOpen.needs_a_person());
574 assert!(!StallReason::PoolFull.needs_a_person());
575 }
576
577 #[test]
578 fn a_run_with_every_provider_out_of_service_is_failed_not_left_running() {
579 let mut world = World::new();
582 world.insert_resource(StallTimeout(60));
583 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
584
585 run(&mut world);
586
587 let status = &world.get::<AgentState>(e).unwrap().status;
588 assert!(
589 matches!(status, AgentStatus::Error { message }
590 if message.contains("out of service") && message.contains("fallback_order")),
591 "got: {status:?}"
592 );
593 assert!(world.get::<ReadyToInfer>(e).is_none());
594 }
595
596 #[test]
597 fn an_open_circuit_inside_the_grace_period_gets_its_chance_to_recover() {
598 let mut world = World::new();
601 world.insert_resource(StallTimeout(60));
602 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 59);
603
604 run(&mut world);
605
606 assert_eq!(
607 world.get::<AgentState>(e).unwrap().status,
608 AgentStatus::Active
609 );
610 }
611
612 #[test]
613 fn the_give_up_message_names_the_provider() {
614 let missing = StallReason::ProviderMissing.give_up_message("ghost");
615 assert!(missing.contains("ghost") && missing.contains("not configured"));
616 let open = StallReason::ProviderCircuitOpen.give_up_message("openrouter");
617 assert!(open.contains("openrouter") && open.contains("out of service"));
618 assert!(StallReason::PoolFull.give_up_message("x").contains("x"));
620 }
621
622 #[test]
623 fn the_default_timeout_is_the_documented_grace_period() {
624 assert_eq!(StallTimeout::default().0, DEFAULT_STALL_TIMEOUT_SECS);
625 }
626}