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 Option<&'static crate::persistence::RunMetadata>,
187);
188
189#[derive(Component, Debug, Clone, PartialEq, Eq)]
196pub struct PausedForSetup {
197 pub blocker: leviath_core::run_meta::SetupBlocker,
200 pub remedy: String,
202}
203
204pub fn fail_stalled_dispatch(
224 mut agents: Query<StalledDispatchQuery>,
225 timeout: Option<Res<StallTimeout>>,
226 clock: Option<Res<StallClock>>,
227 circuits: Option<Res<super::circuit::ProviderCircuits>>,
228 mut commands: Commands,
229) {
230 crate::tick_scope::clear();
231 let limit = timeout.map(|t| t.0).unwrap_or(DEFAULT_STALL_TIMEOUT_SECS);
232 if limit == 0 {
233 return; }
235 let now = clock.map_or_else(now_secs, |c| (c.0)());
236 for (entity, stall, si, mut state, buffer, md) in agents.iter_mut() {
237 crate::tick_scope::enter(entity);
238 if state.status != AgentStatus::Active || !stall.reason.needs_a_person() {
239 continue;
240 }
241 if now.saturating_sub(stall.last_seen) > STALL_FRESHNESS_SECS {
242 tracing::debug!(
244 reason = stall.reason.label(),
245 "discarding a dispatch stall that stopped being refreshed"
246 );
247 commands.entity(entity).remove::<DispatchStall>();
248 continue;
249 }
250 if now.saturating_sub(stall.since) < limit as i64 {
251 continue; }
253 use leviath_core::run_meta::SetupBlocker;
262 let last_reason = circuits
263 .as_ref()
264 .and_then(|c| c.last_reason(&si.provider_name));
265 let blocker = match stall.reason {
266 StallReason::ProviderCircuitOpen => match last_reason {
267 Some(leviath_providers::UnavailableReason::CreditsExhausted) => {
268 SetupBlocker::CreditsExhausted
269 }
270 Some(leviath_providers::UnavailableReason::AuthFailed) => SetupBlocker::AuthFailed,
271 Some(leviath_providers::UnavailableReason::Forbidden) => SetupBlocker::Forbidden,
272 _ => SetupBlocker::ProvidersUnavailable,
276 },
277 _ => SetupBlocker::ProviderMissing,
278 };
279 let message = match blocker {
280 SetupBlocker::CreditsExhausted => format!(
281 "out of credits on '{}': top up the account, then `lev resume` \
282 this run",
283 si.provider_name
284 ),
285 SetupBlocker::AuthFailed => format!(
286 "'{}' rejected the API key: replace it with `lev setup`, then \
287 `lev resume` this run",
288 si.provider_name
289 ),
290 SetupBlocker::Forbidden => format!(
291 "'{}' will not serve this model to that key: check the account's \
292 plan and model permissions, then `lev resume` this run",
293 si.provider_name
294 ),
295 _ => stall.reason.give_up_message(&si.provider_name),
296 };
297 let unattended = md.is_some_and(|m| m.unattended);
302 if unattended {
303 tracing::error!(
304 provider = %si.provider_name,
305 reason = stall.reason.label(),
306 stalled_secs = now.saturating_sub(stall.since),
307 "failing an unattended run: nobody is there to fix it"
308 );
309 if let Some(mut buffer) = buffer {
310 buffer.logs.push((0, format!("[stalled] {message}")));
311 }
312 state.status = AgentStatus::Error { message };
313 commands
314 .entity(entity)
315 .remove::<ReadyToInfer>()
316 .remove::<DispatchStall>();
317 continue;
318 }
319 tracing::warn!(
320 provider = %si.provider_name,
321 reason = stall.reason.label(),
322 stalled_secs = now.saturating_sub(stall.since),
323 "pausing a run until the machine is fixed"
324 );
325 if let Some(mut buffer) = buffer {
326 buffer.logs.push((0, format!("[paused] {message}")));
327 }
328 state.status = AgentStatus::Paused;
329 commands
332 .entity(entity)
333 .insert(PausedForSetup {
334 blocker,
335 remedy: message,
336 })
337 .remove::<DispatchStall>();
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn agent_state() -> AgentState {
346 AgentState {
347 agent_id: "a".to_string(),
348 current_stage: "s".to_string(),
349 iteration: 0,
350 status: AgentStatus::Active,
351 spawned_children_ids: vec![],
352 pending_wait: None,
353 accepts_messages: true,
354 }
355 }
356
357 fn stage_inference() -> StageInference {
358 StageInference {
359 provider_name: "ghost".to_string(),
360 model: "m".to_string(),
361 tools: vec![],
362 tool_filter: None,
363 fallbacks: Vec::new(),
364 output: None,
365 }
366 }
367
368 const NOW: i64 = 1_700_000_000;
373
374 fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
376 DispatchStall {
377 since: NOW - age,
378 last_seen: NOW,
379 reason,
380 }
381 }
382
383 fn spawn_stalled(world: &mut World, reason: StallReason, age: i64) -> Entity {
388 world
389 .spawn((
390 agent_state(),
391 stage_inference(),
392 stalled_for(reason, age),
393 StageIoBuffer::default(),
394 ReadyToInfer,
395 ))
396 .id()
397 }
398
399 fn spawn_stalled_unattended(world: &mut World, reason: StallReason, age: i64) -> Entity {
401 let e = spawn_stalled(world, reason, age);
402 world.entity_mut(e).insert(run_metadata(true));
403 e
404 }
405
406 fn run_metadata(unattended: bool) -> crate::persistence::RunMetadata {
408 crate::persistence::RunMetadata {
409 run_id: "r".to_string(),
410 agent_name: "a".to_string(),
411 agent_path: String::new(),
412 task: String::new(),
413 model: None,
414 workdir: String::new(),
415 num_stages: 1,
416 started_at: 0,
417 parent_run_id: None,
418 metadata: std::collections::HashMap::new(),
419 callback_url: None,
420 callback_secret: None,
421 title: None,
422 unattended,
423 read_paths: None,
424 output_request: None,
425 }
426 }
427
428 fn assert_paused_for_setup(world: &World, e: Entity, remedy: &str) {
430 assert_eq!(
431 world.get::<AgentState>(e).unwrap().status,
432 AgentStatus::Paused,
433 "a fixable problem parks the run rather than ending it"
434 );
435 let marker = world
436 .get::<PausedForSetup>(e)
437 .expect("a parked run says what to do");
438 assert!(marker.remedy.contains(remedy), "{}", marker.remedy);
439 assert!(world.get::<ReadyToInfer>(e).is_some());
442 assert!(world.get::<DispatchStall>(e).is_none());
443 }
444
445 fn run(world: &mut World) {
448 world.insert_resource(StallClock(|| NOW));
449 run_on_the_wall_clock(world);
450 }
451
452 fn run_on_the_wall_clock(world: &mut World) {
454 let mut schedule = Schedule::default();
455 schedule.add_systems(fail_stalled_dispatch);
456 schedule.run(world);
457 }
458
459 #[test]
462 fn a_provider_that_will_never_resolve_parks_the_run_for_a_person() {
463 let mut world = World::new();
464 world.insert_resource(StallTimeout(60));
465 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
466
467 run(&mut world);
468
469 assert_paused_for_setup(&world, e, "not configured");
470 let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
472 assert!(
473 logs.iter().any(|(_, line)| line.starts_with("[paused]")),
474 "expected a [paused] log line, got: {logs:?}"
475 );
476 }
477
478 #[test]
482 fn an_unattended_run_still_fails_because_nobody_will_fix_it() {
483 let mut world = World::new();
484 world.insert_resource(StallTimeout(60));
485 let e = spawn_stalled_unattended(&mut world, StallReason::ProviderMissing, 61);
486
487 run(&mut world);
488
489 let status = &world.get::<AgentState>(e).unwrap().status;
490 assert!(
491 matches!(status, AgentStatus::Error { message }
492 if message.contains("ghost") && message.contains("not configured")),
493 "got: {status:?}"
494 );
495 assert!(world.get::<ReadyToInfer>(e).is_none());
496 assert!(world.get::<PausedForSetup>(e).is_none());
497 let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
498 assert!(
499 logs.iter().any(|(_, line)| line.starts_with("[stalled]")),
500 "expected a [stalled] log line, got: {logs:?}"
501 );
502 }
503
504 #[test]
505 fn a_stall_inside_the_grace_period_is_left_alone() {
506 let mut world = World::new();
507 world.insert_resource(StallTimeout(60));
508 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 59);
509
510 run(&mut world);
511
512 assert_eq!(
513 world.get::<AgentState>(e).unwrap().status,
514 AgentStatus::Active
515 );
516 assert!(world.get::<ReadyToInfer>(e).is_some());
517 }
518
519 #[test]
520 fn the_grace_period_ends_the_second_it_is_reached() {
521 let mut world = World::new();
525 world.insert_resource(StallTimeout(60));
526 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 60);
527
528 run(&mut world);
529
530 assert_paused_for_setup(&world, e, "ghost");
531 }
532
533 #[test]
534 fn nothing_pinning_the_clock_means_the_wall_clock() {
535 let mut world = World::new();
538 world.insert_resource(StallTimeout(60));
539 let now = chrono::Utc::now().timestamp();
540 let e = world
541 .spawn((
542 agent_state(),
543 stage_inference(),
544 DispatchStall {
545 since: now - 10_000,
546 last_seen: now,
547 reason: StallReason::ProviderMissing,
548 },
549 ReadyToInfer,
550 ))
551 .id();
552
553 run_on_the_wall_clock(&mut world);
554
555 assert_paused_for_setup(&world, e, "ghost");
556 }
557
558 #[test]
559 fn a_full_pool_is_backpressure_and_is_never_failed() {
560 let mut world = World::new();
561 world.insert_resource(StallTimeout(60));
562 let e = spawn_stalled(&mut world, StallReason::PoolFull, 10_000);
564
565 run(&mut world);
566
567 assert_eq!(
568 world.get::<AgentState>(e).unwrap().status,
569 AgentStatus::Active
570 );
571 assert!(world.get::<ReadyToInfer>(e).is_some());
572 assert!(world.get::<DispatchStall>(e).is_some());
573 }
574
575 #[test]
576 fn a_zero_timeout_disables_the_watchdog() {
577 let mut world = World::new();
578 world.insert_resource(StallTimeout(0));
579 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
580
581 run(&mut world);
582
583 assert_eq!(
584 world.get::<AgentState>(e).unwrap().status,
585 AgentStatus::Active
586 );
587 }
588
589 #[test]
590 fn a_world_without_the_resource_uses_the_default_timeout() {
591 let mut world = World::new();
593 let inside = spawn_stalled(
594 &mut world,
595 StallReason::ProviderMissing,
596 DEFAULT_STALL_TIMEOUT_SECS as i64 - 1,
597 );
598 let past = spawn_stalled(
599 &mut world,
600 StallReason::ProviderMissing,
601 DEFAULT_STALL_TIMEOUT_SECS as i64 + 1,
602 );
603
604 run(&mut world);
605
606 assert_eq!(
607 world.get::<AgentState>(inside).unwrap().status,
608 AgentStatus::Active
609 );
610 assert_paused_for_setup(&world, past, "ghost");
611 }
612
613 #[test]
614 fn a_non_active_agent_is_left_to_its_own_status() {
615 let mut world = World::new();
618 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
619 world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Paused;
620
621 run(&mut world);
622
623 assert_eq!(
624 world.get::<AgentState>(e).unwrap().status,
625 AgentStatus::Paused
626 );
627 }
628
629 #[test]
630 fn an_agent_without_a_stage_log_still_fails() {
631 let mut world = World::new();
633 let e = world
634 .spawn((
635 agent_state(),
636 stage_inference(),
637 stalled_for(StallReason::ProviderMissing, 10_000),
638 ReadyToInfer,
639 ))
640 .id();
641
642 run(&mut world);
643
644 assert_paused_for_setup(&world, e, "ghost");
645 }
646
647 #[test]
648 fn a_stall_that_stopped_being_refreshed_is_discarded() {
649 let mut world = World::new();
653 let e = world
654 .spawn((
655 agent_state(),
656 stage_inference(),
657 DispatchStall {
658 since: NOW - 10_000,
659 last_seen: NOW - STALL_FRESHNESS_SECS - 1,
660 reason: StallReason::ProviderMissing,
661 },
662 ReadyToInfer,
663 ))
664 .id();
665
666 run(&mut world);
667
668 assert_eq!(
669 world.get::<AgentState>(e).unwrap().status,
670 AgentStatus::Active
671 );
672 assert!(
673 world.get::<DispatchStall>(e).is_none(),
674 "the spent record is cleared rather than left to mislead"
675 );
676 }
677
678 #[test]
679 fn note_stall_continues_a_live_stall_and_restarts_otherwise() {
680 let first = note_stall(None, StallReason::PoolFull, 100);
682 assert_eq!((first.since, first.last_seen), (100, 100));
683 let still = note_stall(Some(&first), StallReason::PoolFull, 120);
684 assert_eq!(still.since, 100, "an ongoing stall keeps its clock");
685 assert_eq!(still.last_seen, 120, "but records that it is still live");
686 let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
688 assert_eq!(changed.since, 120);
689 assert_eq!(changed.reason, StallReason::ProviderMissing);
690 let resumed = note_stall(
692 Some(&first),
693 StallReason::PoolFull,
694 100 + STALL_FRESHNESS_SECS + 1,
695 );
696 assert_eq!(resumed.since, 100 + STALL_FRESHNESS_SECS + 1);
697 }
698
699 #[test]
700 fn stall_reasons_have_labels() {
701 assert_eq!(StallReason::ProviderMissing.label(), "provider-missing");
702 assert_eq!(StallReason::PoolFull.label(), "pool-full");
703 assert_eq!(
704 StallReason::ProviderCircuitOpen.label(),
705 "provider-circuit-open"
706 );
707 }
708
709 #[test]
710 fn only_the_reasons_a_person_must_fix_are_failed() {
711 assert!(StallReason::ProviderMissing.needs_a_person());
713 assert!(StallReason::ProviderCircuitOpen.needs_a_person());
714 assert!(!StallReason::PoolFull.needs_a_person());
715 }
716
717 #[test]
718 fn a_run_with_every_provider_out_of_service_is_failed_not_left_running() {
719 let mut world = World::new();
722 world.insert_resource(StallTimeout(60));
723 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
724
725 run(&mut world);
726
727 assert_paused_for_setup(&world, e, "out of service");
728 }
729
730 #[test]
731 fn a_run_out_of_credits_is_paused_for_a_resume_not_failed() {
732 let mut world = World::new();
737 world.insert_resource(StallTimeout(60));
738 let mut circuits = super::super::circuit::ProviderCircuits::default();
739 let policy = super::super::circuit::CircuitPolicy::default();
740 for i in 0..3 {
741 circuits.record_failure(
742 "ghost",
743 leviath_providers::UnavailableReason::CreditsExhausted,
744 NOW - 3 + i,
745 &policy,
746 );
747 }
748 world.insert_resource(circuits);
749 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
750
751 run(&mut world);
752
753 assert_eq!(
754 world.get::<AgentState>(e).unwrap().status,
755 AgentStatus::Paused
756 );
757 assert!(
758 world.get::<ReadyToInfer>(e).is_some(),
759 "the retry is staged"
760 );
761 assert!(world.get::<DispatchStall>(e).is_none());
762 let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
763 let line = logs
764 .iter()
765 .map(|(_, l)| l.as_str())
766 .find(|l| l.starts_with("[paused]"))
767 .expect("the pause is written to the stage log");
768 assert!(line.contains("out of credits"), "{line}");
769 assert!(line.contains("lev resume"), "{line}");
770 }
771
772 #[test]
773 fn the_credits_pause_copes_without_a_stage_log_buffer() {
774 let mut world = World::new();
777 world.insert_resource(StallTimeout(60));
778 let mut circuits = super::super::circuit::ProviderCircuits::default();
779 let policy = super::super::circuit::CircuitPolicy::default();
780 for i in 0..3 {
781 circuits.record_failure(
782 "ghost",
783 leviath_providers::UnavailableReason::CreditsExhausted,
784 NOW - 3 + i,
785 &policy,
786 );
787 }
788 world.insert_resource(circuits);
789 let e = world
790 .spawn((
791 agent_state(),
792 stage_inference(),
793 stalled_for(StallReason::ProviderCircuitOpen, 61),
794 ReadyToInfer,
795 ))
796 .id();
797
798 run(&mut world);
799
800 assert_eq!(
801 world.get::<AgentState>(e).unwrap().status,
802 AgentStatus::Paused
803 );
804 }
805
806 #[test]
811 fn each_kind_of_provider_failure_names_its_own_remedy() {
812 use leviath_core::run_meta::SetupBlocker;
813 let cases = [
814 (
815 leviath_providers::UnavailableReason::CreditsExhausted,
816 SetupBlocker::CreditsExhausted,
817 "top up",
818 ),
819 (
820 leviath_providers::UnavailableReason::AuthFailed,
821 SetupBlocker::AuthFailed,
822 "rejected the API key",
823 ),
824 (
825 leviath_providers::UnavailableReason::Forbidden,
826 SetupBlocker::Forbidden,
827 "will not serve this model",
828 ),
829 (
830 leviath_providers::UnavailableReason::Unreachable,
833 SetupBlocker::ProvidersUnavailable,
834 "out of service",
835 ),
836 ];
837 for (reason, expected, remedy) in cases {
838 let mut world = World::new();
839 world.insert_resource(StallTimeout(60));
840 let mut circuits = super::super::circuit::ProviderCircuits::default();
841 let policy = super::super::circuit::CircuitPolicy::default();
842 circuits.record_failure("ghost", reason, NOW - 1, &policy);
843 world.insert_resource(circuits);
844 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
845
846 run(&mut world);
847
848 assert_paused_for_setup(&world, e, remedy);
849 assert_eq!(
850 world.get::<PausedForSetup>(e).unwrap().blocker,
851 expected,
852 "{reason:?}"
853 );
854 }
855 }
856
857 #[test]
860 fn an_unattended_failure_copes_without_a_stage_log_buffer() {
861 let mut world = World::new();
862 world.insert_resource(StallTimeout(60));
863 let e = world
864 .spawn((
865 agent_state(),
866 stage_inference(),
867 stalled_for(StallReason::ProviderMissing, 61),
868 run_metadata(true),
869 ReadyToInfer,
870 ))
871 .id();
872
873 run(&mut world);
874
875 let status = format!("{:?}", world.get::<AgentState>(e).unwrap().status);
876 assert!(status.contains("Error"), "{status}");
877 }
878
879 #[test]
882 fn a_missing_provider_is_its_own_kind_of_blocker() {
883 use leviath_core::run_meta::SetupBlocker;
884 let mut world = World::new();
885 world.insert_resource(StallTimeout(60));
886 let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
887
888 run(&mut world);
889
890 assert_eq!(
891 world.get::<PausedForSetup>(e).unwrap().blocker,
892 SetupBlocker::ProviderMissing
893 );
894 }
895
896 #[test]
897 fn an_open_circuit_inside_the_grace_period_gets_its_chance_to_recover() {
898 let mut world = World::new();
901 world.insert_resource(StallTimeout(60));
902 let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 59);
903
904 run(&mut world);
905
906 assert_eq!(
907 world.get::<AgentState>(e).unwrap().status,
908 AgentStatus::Active
909 );
910 }
911
912 #[test]
913 fn the_give_up_message_names_the_provider() {
914 let missing = StallReason::ProviderMissing.give_up_message("ghost");
915 assert!(missing.contains("ghost") && missing.contains("not configured"));
916 let open = StallReason::ProviderCircuitOpen.give_up_message("openrouter");
917 assert!(open.contains("openrouter") && open.contains("out of service"));
918 assert!(StallReason::PoolFull.give_up_message("x").contains("x"));
920 }
921
922 #[test]
923 fn the_default_timeout_is_the_documented_grace_period() {
924 assert_eq!(StallTimeout::default().0, DEFAULT_STALL_TIMEOUT_SECS);
925 }
926}