Skip to main content

leviath_runtime/pipeline/
stall.rs

1//! The dispatch-stall watchdog: fail a run that is runnable but can never run.
2
3use super::*;
4
5/// Why a dispatch system declined to start work for an agent this tick.
6///
7/// The two cases look identical from the outside - the agent keeps its
8/// `ReadyToInfer` marker either way - but they are opposites in kind, which is
9/// what the watchdog acts on.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum StallReason {
12    /// The stage names a provider that is not in the registry. Nothing the
13    /// runtime does will change that: no work is in flight to finish, no permit
14    /// will free up. Only editing the config and restarting the daemon (or
15    /// dropping in the matching `.rhai` script) can.
16    ProviderMissing,
17    /// The model's inference pool is full. This is ordinary backpressure and
18    /// resolves itself: every permit is held by a job that the job timeout
19    /// bounds, and releasing one wakes the driver.
20    PoolFull,
21    /// Every provider this stage could use has an open circuit: they have each
22    /// failed enough consecutive times to be taken out of service, and the
23    /// stage has no candidate left to move to (issue #201).
24    ///
25    /// Unlike `PoolFull` this will not clear on its own within a tick or two -
26    /// somebody has to top up an account or fix a key - so the watchdog fails
27    /// it like `ProviderMissing`. Unlike `ProviderMissing` it *can* recover
28    /// without a restart, which is what the grace period is for.
29    ProviderCircuitOpen,
30}
31
32impl StallReason {
33    /// A short label for logs.
34    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    /// Whether the runtime can resolve this on its own given time.
43    ///
44    /// `PoolFull` clears itself the moment a permit frees, so failing a run for
45    /// it would be failing backpressure. The other two need a person, and a run
46    /// that waits on one for ever reads as healthy while going nowhere.
47    fn needs_a_person(self) -> bool {
48        match self {
49            StallReason::ProviderMissing | StallReason::ProviderCircuitOpen => true,
50            StallReason::PoolFull => false,
51        }
52    }
53
54    /// The operator-facing explanation used when the watchdog gives up.
55    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            // `PoolFull` never reaches the watchdog (see `needs_a_person`), so
64            // the missing-provider wording covers the remaining case.
65            _ => 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/// An agent that was ready to work but whose dispatch declined, and since when.
74///
75/// Attached by the dispatch systems when they decline, refreshed while the same
76/// reason persists, and removed the moment work is dispatched - so its presence
77/// means "runnable right now, and has been going nowhere since `since`".
78#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
79pub struct DispatchStall {
80    /// Unix seconds when this stall started (not when it was last observed, so
81    /// the age is the whole stall).
82    pub since: i64,
83    /// Unix seconds of the most recent decline.
84    ///
85    /// This is what keeps the record honest. An agent can leave the ready state
86    /// for reasons that have nothing to do with dispatch - a stuck edge, an
87    /// iteration cap - and come back later; without a freshness stamp it would
88    /// return carrying an ancient `since` and be judged on a wait it was not
89    /// actually doing. A record that stops being refreshed simply expires.
90    pub last_seen: i64,
91    /// What is holding the agent up.
92    pub reason: StallReason,
93}
94
95/// How long a [`DispatchStall`] stays meaningful without being refreshed.
96///
97/// The dispatch systems re-stamp it on every tick they decline, and the host
98/// re-drives at least once per `DEFAULT_REDRIVE_INTERVAL` (30s), so a live
99/// stall is never more than one interval stale. This is comfortably above that
100/// so an ongoing stall is never mistaken for an abandoned record; anything
101/// older than this really does describe a wait that has since ended.
102pub(crate) const STALL_FRESHNESS_SECS: i64 = 120;
103
104/// How long a `ProviderMissing` stall may last before the run is failed.
105///
106/// A world resource rather than a constant because the daemon serves it from
107/// `[limits] stall_timeout_secs`. Zero disables the watchdog.
108#[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/// The clock the watchdog measures stall ages against.
118///
119/// Absent in production, where the wall clock is the only sensible answer. It
120/// exists so a test can pin the instant the watchdog reads, and that matters
121/// more than it looks: a stall's age is the gap between *two* clock reads - the
122/// one that stamped `since` and the one this system does - so a second boundary
123/// falling between them shifts every age by one. That is enough to flip a case
124/// deliberately sitting one second inside the grace period, which turns a
125/// boundary test into a coin toss that lands wrong on a loaded runner.
126#[derive(Resource, Debug, Clone, Copy)]
127pub struct StallClock(
128    /// Returns Unix seconds. A bare `fn` rather than a boxed closure so the
129    /// resource stays `Copy` and costs nothing when it is absent.
130    pub fn() -> i64,
131);
132
133/// Wall-clock seconds since the Unix epoch: what the watchdog reads when no
134/// [`StallClock`] pins it.
135fn now_secs() -> i64 {
136    chrono::Utc::now().timestamp()
137}
138
139/// Default grace period before an unresolvable stall fails its run.
140///
141/// Long enough that a provider arriving late - a `.rhai` script dropped into the
142/// providers directory resolves on the next dispatch - still rescues the run,
143/// short enough that an operator watching `lev ps` gets an answer rather than a
144/// run that claims to be working.
145pub const DEFAULT_STALL_TIMEOUT_SECS: u64 = 60;
146
147/// Record that an agent's dispatch declined for `reason`, preserving the start
148/// time of an ongoing stall of the same kind.
149///
150/// The clock restarts unless this continues a stall that is both the *same
151/// kind* and still fresh. A changed reason is a different problem and deserves
152/// its own grace period rather than inheriting the age of the old one; a stale
153/// record describes a wait that already ended (see
154/// [`STALL_FRESHNESS_SECS`]).
155pub(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
176/// What `fail_stalled_dispatch` selects.
177///
178/// `&'static` is bevy's `WorldQuery` convention, not a claim about
179/// lifetimes: the borrow is bound when the query is fetched.
180type StalledDispatchQuery = (
181    Entity,
182    &'static DispatchStall,
183    &'static StageInference,
184    &'static mut AgentState,
185    Option<&'static mut StageIoBuffer>,
186);
187
188/// Dispatch-stall watchdog: fail any agent whose dispatch has been declining for
189/// an unresolvable reason longer than [`StallTimeout`].
190///
191/// This is the backstop under issue #190. A stage pointing at a provider that
192/// isn't registered leaves the agent `Active` and `ReadyToInfer` with nothing in
193/// flight - so from the outside it reads as a healthy running run, for ever, at
194/// iteration 0. The daemon now re-ticks on a heartbeat, which makes the retry
195/// real, but retrying a provider that will never exist just means failing
196/// quietly for ever instead of loudly once.
197///
198/// Only [`StallReason::ProviderMissing`] is failed. A full pool is deliberately
199/// exempt: it is what backpressure is supposed to look like, and a run waiting
200/// its turn behind seven long inferences is working exactly as intended.
201///
202/// What makes this safe from false positives is *which* agents can carry a
203/// [`DispatchStall`] at all. Only a dispatch system that declined attaches one,
204/// and dispatching removes it - so an agent holding one has nothing
205/// outstanding. A fifteen-minute inference is `AwaitingInference` with no stall
206/// record, and is never a candidate here.
207pub 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; // watchdog disabled
218    }
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            // The wait this describes has ended; nothing to act on.
227            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; // still inside the grace period
236        }
237        // A circuit that opened because the account ran out of credits is an
238        // account state, not a dead end: pause the run for a resume instead of
239        // failing it, keeping `ReadyToInfer` so the retry is already staged
240        // (issue #413). Any other reason still fails below - a missing
241        // provider or a rejected key does not fix itself with a top-up.
242        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    /// The instant these tests pretend it is, on both sides of the comparison.
311    ///
312    /// Arbitrary, and deliberately not the wall clock: see [`StallClock`] for
313    /// why reading it twice makes a boundary test flaky.
314    const NOW: i64 = 1_700_000_000;
315
316    /// A stall that started `age` seconds ago and is still being refreshed.
317    fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
318        DispatchStall {
319            since: NOW - age,
320            last_seen: NOW,
321            reason,
322        }
323    }
324
325    /// Spawn an agent that has been stalled for `age` seconds for `reason`.
326    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    /// Run the watchdog with the clock pinned to [`NOW`], so an age of `n` is
339    /// exactly `n` and the grace boundary can be asserted to the second.
340    fn run(world: &mut World) {
341        world.insert_resource(StallClock(|| NOW));
342        run_on_the_wall_clock(world);
343    }
344
345    /// Run it the way production does, with no clock pinned.
346    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        // Taken out of dispatch, and the stall record is spent.
367        assert!(world.get::<ReadyToInfer>(e).is_none());
368        assert!(world.get::<DispatchStall>(e).is_none());
369        // The operator sees why in the stage log the dashboard renders.
370        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        // `<` rather than `<=`, so an age equal to the limit is already out of
395        // grace. Only worth asserting because the clock is pinned - against the
396        // wall clock this is the exact case a one-second drift inverts.
397        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        // Production inserts no `StallClock`. The age here is far enough past
413        // the limit that no drift between the two reads can change the verdict.
414        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        // Far past the grace period: waiting behind long inferences is fine.
444        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        // Test worlds and `lev run` don't insert `StallTimeout`.
473        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        // A paused run is not stalled - it is stopped on purpose, and resuming
501        // it must not find it failed.
502        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        // `StageIoBuffer` is optional (test worlds, `lev run`).
517        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        // The agent left the ready state for some unrelated reason (a stuck
539        // edge, an iteration cap) and came back. It must not be judged on a
540        // wait it was not actually doing.
541        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        // A continuing stall keeps its start time, so the age is the whole wait.
570        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        // A different reason is a different problem: it gets its own grace.
576        let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
577        assert_eq!(changed.since, 120);
578        assert_eq!(changed.reason, StallReason::ProviderMissing);
579        // So does a stall that went unobserved long enough to have ended.
580        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        // Failing `PoolFull` would be failing backpressure.
601        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        // The end state of issue #201: nothing left to fail over to. Waiting
609        // for ever reads as a healthy run that is going nowhere.
610        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        // Issue #413: exhausted credits are an account state the operator can
628        // fix, so the watchdog pauses the run instead of ending it. The
629        // `ReadyToInfer` marker stays, so a resume re-dispatches the same
630        // inference.
631        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        // `StageIoBuffer` is optional on the query, so the pause has to land
670        // even when there is no stage log to explain it in.
671        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        // The pause is only for credits: a rejected key does not fix itself
704        // with a top-up, so any other recorded reason keeps today's failure.
705        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        // Unlike a missing provider, this one can come back on its own once
730        // the cooldown lets a probe through, so the grace period matters.
731        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        // `PoolFull` never reaches the watchdog, but the arm must still answer.
750        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}