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 circuits: Option<Res<super::circuit::ProviderCircuits>>,
212 mut commands: Commands,
213) {
214 crate::tick_scope::clear();
215 let limit = timeout.map(|t| t.0).unwrap_or(DEFAULT_STALL_TIMEOUT_SECS);
216 if limit == 0 {
217 return; }
219 let now = clock.map_or_else(now_secs, |c| (c.0)());
220 for (entity, stall, si, mut state, buffer) in agents.iter_mut() {
221 crate::tick_scope::enter(entity);
222 if state.status != AgentStatus::Active || !stall.reason.needs_a_person() {
223 continue;
224 }
225 if now.saturating_sub(stall.last_seen) > STALL_FRESHNESS_SECS {
226 tracing::debug!(
228 reason = stall.reason.label(),
229 "discarding a dispatch stall that stopped being refreshed"
230 );
231 commands.entity(entity).remove::<DispatchStall>();
232 continue;
233 }
234 if now.saturating_sub(stall.since) < limit as i64 {
235 continue; }
237 let credits_out = stall.reason == StallReason::ProviderCircuitOpen
243 && circuits
244 .as_ref()
245 .and_then(|c| c.last_reason(&si.provider_name))
246 == Some(leviath_providers::UnavailableReason::CreditsExhausted);
247 if credits_out {
248 let message = format!(
249 "out of credits on '{}'; pausing this run - top up the account, \
250 then `lev resume` it",
251 si.provider_name
252 );
253 tracing::warn!(
254 provider = %si.provider_name,
255 stalled_secs = now.saturating_sub(stall.since),
256 "out of credits; pausing the run for a resume"
257 );
258 if let Some(mut buffer) = buffer {
259 buffer.logs.push((0, format!("[paused] {message}")));
260 }
261 state.status = AgentStatus::Paused;
262 commands.entity(entity).remove::<DispatchStall>();
263 continue;
264 }
265 let message = stall.reason.give_up_message(&si.provider_name);
266 tracing::error!(
267 provider = %si.provider_name,
268 reason = stall.reason.label(),
269 stalled_secs = now.saturating_sub(stall.since),
270 "failing a run whose provider will never resolve"
271 );
272 if let Some(mut buffer) = buffer {
273 buffer.logs.push((0, format!("[stalled] {message}")));
274 }
275 state.status = AgentStatus::Error { message };
276 commands
277 .entity(entity)
278 .remove::<ReadyToInfer>()
279 .remove::<DispatchStall>();
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn agent_state() -> AgentState {
288 AgentState {
289 agent_id: "a".to_string(),
290 current_stage: "s".to_string(),
291 iteration: 0,
292 status: AgentStatus::Active,
293 spawned_children_ids: vec![],
294 pending_wait: None,
295 accepts_messages: true,
296 }
297 }
298
299 fn stage_inference() -> StageInference {
300 StageInference {
301 provider_name: "ghost".to_string(),
302 model: "m".to_string(),
303 tools: vec![],
304 tool_filter: None,
305 fallbacks: Vec::new(),
306 output: None,
307 }
308 }
309
310 const NOW: i64 = 1_700_000_000;
315
316 fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
318 DispatchStall {
319 since: NOW - age,
320 last_seen: NOW,
321 reason,
322 }
323 }
324
325 fn spawn_stalled(world: &mut World, reason: StallReason, age: i64) -> Entity {
327 world
328 .spawn((
329 agent_state(),
330 stage_inference(),
331 stalled_for(reason, age),
332 StageIoBuffer::default(),
333 ReadyToInfer,
334 ))
335 .id()
336 }
337
338 fn run(world: &mut World) {
341 world.insert_resource(StallClock(|| NOW));
342 run_on_the_wall_clock(world);
343 }
344
345 fn run_on_the_wall_clock(world: &mut World) {
347 let mut schedule = Schedule::default();
348 schedule.add_systems(fail_stalled_dispatch);
349 schedule.run(world);
350 }
351
352 #[test]
353 fn a_provider_that_will_never_resolve_fails_the_run() {
354 let mut world = World::new();
355 world.insert_resource(StallTimeout(60));
356 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
357
358 run(&mut world);
359
360 let status = &world.get::<AgentState>(e).unwrap().status;
361 assert!(
362 matches!(status, AgentStatus::Error { message }
363 if message.contains("ghost") && message.contains("not configured")),
364 "got: {status:?}"
365 );
366 assert!(world.get::<ReadyToInfer>(e).is_none());
368 assert!(world.get::<DispatchStall>(e).is_none());
369 let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
371 assert!(
372 logs.iter().any(|(_, line)| line.starts_with("[stalled]")),
373 "expected a [stalled] log line, got: {logs:?}"
374 );
375 }
376
377 #[test]
378 fn a_stall_inside_the_grace_period_is_left_alone() {
379 let mut world = World::new();
380 world.insert_resource(StallTimeout(60));
381 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 59);
382
383 run(&mut world);
384
385 assert_eq!(
386 world.get::<AgentState>(e).unwrap().status,
387 AgentStatus::Active
388 );
389 assert!(world.get::<ReadyToInfer>(e).is_some());
390 }
391
392 #[test]
393 fn the_grace_period_ends_the_second_it_is_reached() {
394 let mut world = World::new();
398 world.insert_resource(StallTimeout(60));
399 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 60);
400
401 run(&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 nothing_pinning_the_clock_means_the_wall_clock() {
412 let mut world = World::new();
415 world.insert_resource(StallTimeout(60));
416 let now = chrono::Utc::now().timestamp();
417 let e = world
418 .spawn((
419 agent_state(),
420 stage_inference(),
421 DispatchStall {
422 since: now - 10_000,
423 last_seen: now,
424 reason: StallReason::ProviderMissing,
425 },
426 ReadyToInfer,
427 ))
428 .id();
429
430 run_on_the_wall_clock(&mut world);
431
432 let status = &world.get::<AgentState>(e).unwrap().status;
433 assert!(
434 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
435 "got: {status:?}"
436 );
437 }
438
439 #[test]
440 fn a_full_pool_is_backpressure_and_is_never_failed() {
441 let mut world = World::new();
442 world.insert_resource(StallTimeout(60));
443 let e = spawn_stalled(&mut world, StallReason::PoolFull, 10_000);
445
446 run(&mut world);
447
448 assert_eq!(
449 world.get::<AgentState>(e).unwrap().status,
450 AgentStatus::Active
451 );
452 assert!(world.get::<ReadyToInfer>(e).is_some());
453 assert!(world.get::<DispatchStall>(e).is_some());
454 }
455
456 #[test]
457 fn a_zero_timeout_disables_the_watchdog() {
458 let mut world = World::new();
459 world.insert_resource(StallTimeout(0));
460 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
461
462 run(&mut world);
463
464 assert_eq!(
465 world.get::<AgentState>(e).unwrap().status,
466 AgentStatus::Active
467 );
468 }
469
470 #[test]
471 fn a_world_without_the_resource_uses_the_default_timeout() {
472 let mut world = World::new();
474 let inside = spawn_stalled(
475 &mut world,
476 StallReason::ProviderMissing,
477 DEFAULT_STALL_TIMEOUT_SECS as i64 - 1,
478 );
479 let past = spawn_stalled(
480 &mut world,
481 StallReason::ProviderMissing,
482 DEFAULT_STALL_TIMEOUT_SECS as i64 + 1,
483 );
484
485 run(&mut world);
486
487 assert_eq!(
488 world.get::<AgentState>(inside).unwrap().status,
489 AgentStatus::Active
490 );
491 let status = &world.get::<AgentState>(past).unwrap().status;
492 assert!(
493 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
494 "got: {status:?}"
495 );
496 }
497
498 #[test]
499 fn a_non_active_agent_is_left_to_its_own_status() {
500 let mut world = World::new();
503 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
504 world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Paused;
505
506 run(&mut world);
507
508 assert_eq!(
509 world.get::<AgentState>(e).unwrap().status,
510 AgentStatus::Paused
511 );
512 }
513
514 #[test]
515 fn an_agent_without_a_stage_log_still_fails() {
516 let mut world = World::new();
518 let e = world
519 .spawn((
520 agent_state(),
521 stage_inference(),
522 stalled_for(StallReason::ProviderMissing, 10_000),
523 ReadyToInfer,
524 ))
525 .id();
526
527 run(&mut world);
528
529 let status = &world.get::<AgentState>(e).unwrap().status;
530 assert!(
531 matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
532 "got: {status:?}"
533 );
534 }
535
536 #[test]
537 fn a_stall_that_stopped_being_refreshed_is_discarded() {
538 let mut world = World::new();
542 let e = world
543 .spawn((
544 agent_state(),
545 stage_inference(),
546 DispatchStall {
547 since: NOW - 10_000,
548 last_seen: NOW - STALL_FRESHNESS_SECS - 1,
549 reason: StallReason::ProviderMissing,
550 },
551 ReadyToInfer,
552 ))
553 .id();
554
555 run(&mut world);
556
557 assert_eq!(
558 world.get::<AgentState>(e).unwrap().status,
559 AgentStatus::Active
560 );
561 assert!(
562 world.get::<DispatchStall>(e).is_none(),
563 "the spent record is cleared rather than left to mislead"
564 );
565 }
566
567 #[test]
568 fn note_stall_continues_a_live_stall_and_restarts_otherwise() {
569 let first = note_stall(None, StallReason::PoolFull, 100);
571 assert_eq!((first.since, first.last_seen), (100, 100));
572 let still = note_stall(Some(&first), StallReason::PoolFull, 120);
573 assert_eq!(still.since, 100, "an ongoing stall keeps its clock");
574 assert_eq!(still.last_seen, 120, "but records that it is still live");
575 let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
577 assert_eq!(changed.since, 120);
578 assert_eq!(changed.reason, StallReason::ProviderMissing);
579 let resumed = note_stall(
581 Some(&first),
582 StallReason::PoolFull,
583 100 + STALL_FRESHNESS_SECS + 1,
584 );
585 assert_eq!(resumed.since, 100 + STALL_FRESHNESS_SECS + 1);
586 }
587
588 #[test]
589 fn stall_reasons_have_labels() {
590 assert_eq!(StallReason::ProviderMissing.label(), "provider-missing");
591 assert_eq!(StallReason::PoolFull.label(), "pool-full");
592 assert_eq!(
593 StallReason::ProviderCircuitOpen.label(),
594 "provider-circuit-open"
595 );
596 }
597
598 #[test]
599 fn only_the_reasons_a_person_must_fix_are_failed() {
600 assert!(StallReason::ProviderMissing.needs_a_person());
602 assert!(StallReason::ProviderCircuitOpen.needs_a_person());
603 assert!(!StallReason::PoolFull.needs_a_person());
604 }
605
606 #[test]
607 fn a_run_with_every_provider_out_of_service_is_failed_not_left_running() {
608 let mut world = World::new();
611 world.insert_resource(StallTimeout(60));
612 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
613
614 run(&mut world);
615
616 let status = &world.get::<AgentState>(e).unwrap().status;
617 assert!(
618 matches!(status, AgentStatus::Error { message }
619 if message.contains("out of service") && message.contains("fallback_order")),
620 "got: {status:?}"
621 );
622 assert!(world.get::<ReadyToInfer>(e).is_none());
623 }
624
625 #[test]
626 fn a_run_out_of_credits_is_paused_for_a_resume_not_failed() {
627 let mut world = World::new();
632 world.insert_resource(StallTimeout(60));
633 let mut circuits = super::super::circuit::ProviderCircuits::default();
634 let policy = super::super::circuit::CircuitPolicy::default();
635 for i in 0..3 {
636 circuits.record_failure(
637 "ghost",
638 leviath_providers::UnavailableReason::CreditsExhausted,
639 NOW - 3 + i,
640 &policy,
641 );
642 }
643 world.insert_resource(circuits);
644 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
645
646 run(&mut world);
647
648 assert_eq!(
649 world.get::<AgentState>(e).unwrap().status,
650 AgentStatus::Paused
651 );
652 assert!(
653 world.get::<ReadyToInfer>(e).is_some(),
654 "the retry is staged"
655 );
656 assert!(world.get::<DispatchStall>(e).is_none());
657 let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
658 let line = logs
659 .iter()
660 .map(|(_, l)| l.as_str())
661 .find(|l| l.starts_with("[paused]"))
662 .expect("the pause is written to the stage log");
663 assert!(line.contains("out of credits"), "{line}");
664 assert!(line.contains("lev resume"), "{line}");
665 }
666
667 #[test]
668 fn the_credits_pause_copes_without_a_stage_log_buffer() {
669 let mut world = World::new();
672 world.insert_resource(StallTimeout(60));
673 let mut circuits = super::super::circuit::ProviderCircuits::default();
674 let policy = super::super::circuit::CircuitPolicy::default();
675 for i in 0..3 {
676 circuits.record_failure(
677 "ghost",
678 leviath_providers::UnavailableReason::CreditsExhausted,
679 NOW - 3 + i,
680 &policy,
681 );
682 }
683 world.insert_resource(circuits);
684 let e = world
685 .spawn((
686 agent_state(),
687 stage_inference(),
688 stalled_for(StallReason::ProviderCircuitOpen, 61),
689 ReadyToInfer,
690 ))
691 .id();
692
693 run(&mut world);
694
695 assert_eq!(
696 world.get::<AgentState>(e).unwrap().status,
697 AgentStatus::Paused
698 );
699 }
700
701 #[test]
702 fn a_circuit_open_for_a_dead_key_still_fails_the_run() {
703 let mut world = World::new();
706 world.insert_resource(StallTimeout(60));
707 let mut circuits = super::super::circuit::ProviderCircuits::default();
708 let policy = super::super::circuit::CircuitPolicy::default();
709 circuits.record_failure(
710 "ghost",
711 leviath_providers::UnavailableReason::AuthFailed,
712 NOW - 1,
713 &policy,
714 );
715 world.insert_resource(circuits);
716 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
717
718 run(&mut world);
719
720 let status = &world.get::<AgentState>(e).unwrap().status;
721 assert!(
722 matches!(status, AgentStatus::Error { message } if message.contains("out of service")),
723 "got: {status:?}"
724 );
725 }
726
727 #[test]
728 fn an_open_circuit_inside_the_grace_period_gets_its_chance_to_recover() {
729 let mut world = World::new();
732 world.insert_resource(StallTimeout(60));
733 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 59);
734
735 run(&mut world);
736
737 assert_eq!(
738 world.get::<AgentState>(e).unwrap().status,
739 AgentStatus::Active
740 );
741 }
742
743 #[test]
744 fn the_give_up_message_names_the_provider() {
745 let missing = StallReason::ProviderMissing.give_up_message("ghost");
746 assert!(missing.contains("ghost") && missing.contains("not configured"));
747 let open = StallReason::ProviderCircuitOpen.give_up_message("openrouter");
748 assert!(open.contains("openrouter") && open.contains("out of service"));
749 assert!(StallReason::PoolFull.give_up_message("x").contains("x"));
751 }
752
753 #[test]
754 fn the_default_timeout_is_the_documented_grace_period() {
755 assert_eq!(StallTimeout::default().0, DEFAULT_STALL_TIMEOUT_SECS);
756 }
757}