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    Option<&'static crate::persistence::RunMetadata>,
187);
188
189/// A run parked until the machine is fixed, and what to do about it.
190///
191/// The message is the same one that used to be the run's epitaph. It is now
192/// attached to a run that is still alive, which is the whole change: every
193/// reason this marker exists for is deterministic, outside the run's control,
194/// and undone by one edit somewhere else.
195#[derive(Component, Debug, Clone, PartialEq, Eq)]
196pub struct PausedForSetup {
197    /// Which kind of problem, so a client can offer the right remedy rather
198    /// than match on the sentence.
199    pub blocker: leviath_core::run_meta::SetupBlocker,
200    /// What a person has to do before `lev resume` will get anywhere.
201    pub remedy: String,
202}
203
204/// Dispatch-stall watchdog: fail any agent whose dispatch has been declining for
205/// an unresolvable reason longer than [`StallTimeout`].
206///
207/// This is the backstop under issue #190. A stage pointing at a provider that
208/// isn't registered leaves the agent `Active` and `ReadyToInfer` with nothing in
209/// flight - so from the outside it reads as a healthy running run, for ever, at
210/// iteration 0. The daemon now re-ticks on a heartbeat, which makes the retry
211/// real, but retrying a provider that will never exist just means failing
212/// quietly for ever instead of loudly once.
213///
214/// Only [`StallReason::ProviderMissing`] is failed. A full pool is deliberately
215/// exempt: it is what backpressure is supposed to look like, and a run waiting
216/// its turn behind seven long inferences is working exactly as intended.
217///
218/// What makes this safe from false positives is *which* agents can carry a
219/// [`DispatchStall`] at all. Only a dispatch system that declined attaches one,
220/// and dispatching removes it - so an agent holding one has nothing
221/// outstanding. A fifteen-minute inference is `AwaitingInference` with no stall
222/// record, and is never a candidate here.
223pub 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; // watchdog disabled
234    }
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            // The wait this describes has ended; nothing to act on.
243            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; // still inside the grace period
252        }
253        // Everything that reaches here is deterministic and outside the run's
254        // control: a provider that is not configured, a key that was rejected,
255        // an account with no credits. One edit elsewhere undoes any of them,
256        // and the run's context is intact, so ending it would throw the work
257        // away to punish somebody for a typo. It waits instead.
258        // Which kind of problem, from the breaker's own record of why each
259        // provider went out of service. The three have three different fixes,
260        // so they are three different answers rather than one "unavailable".
261        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                // Unreachable, or nothing recorded: the account and the key
273                // are both fine as far as anyone knows, so neither screen is
274                // the right one to send somebody to.
275                _ => 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        // Unless nobody is there to read it. An unattended run was launched by
298        // a scheduler or a harness, which is watching for a terminal status
299        // and will wait for ever for one that never comes - so for those,
300        // failing is the honest answer and stays what it was.
301        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        // `ReadyToInfer` stays on purpose: the retry is already staged, so a
330        // resume re-dispatches rather than rebuilding anything.
331        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    /// The instant these tests pretend it is, on both sides of the comparison.
369    ///
370    /// Arbitrary, and deliberately not the wall clock: see [`StallClock`] for
371    /// why reading it twice makes a boundary test flaky.
372    const NOW: i64 = 1_700_000_000;
373
374    /// A stall that started `age` seconds ago and is still being refreshed.
375    fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
376        DispatchStall {
377            since: NOW - age,
378            last_seen: NOW,
379            reason,
380        }
381    }
382
383    /// Spawn an agent that has been stalled for `age` seconds for `reason`.
384    ///
385    /// Attended, because that is the ordinary case: a person started it and
386    /// can fix whatever is wrong. [`spawn_stalled_unattended`] is the other.
387    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    /// The same, launched by something that is not watching.
400    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    /// Run metadata carrying only the field the watchdog reads.
407    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    /// Assert a run is parked for setup, with `remedy` naming what to do.
429    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        // The retry stays staged, so a resume re-dispatches rather than
440        // rebuilding anything.
441        assert!(world.get::<ReadyToInfer>(e).is_some());
442        assert!(world.get::<DispatchStall>(e).is_none());
443    }
444
445    /// Run the watchdog with the clock pinned to [`NOW`], so an age of `n` is
446    /// exactly `n` and the grace boundary can be asserted to the second.
447    fn run(world: &mut World) {
448        world.insert_resource(StallClock(|| NOW));
449        run_on_the_wall_clock(world);
450    }
451
452    /// Run it the way production does, with no clock pinned.
453    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    /// A provider that is not configured is one config edit away from being
460    /// configured, so the run waits for that edit instead of dying for it.
461    #[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        // The operator sees why in the stage log the dashboard renders.
471        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    /// Unless nobody is watching. A scheduler polling for a terminal status
479    /// would wait for ever for one that never came, so an unattended run still
480    /// fails - the one case where an error is the more useful answer.
481    #[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        // `<` rather than `<=`, so an age equal to the limit is already out of
522        // grace. Only worth asserting because the clock is pinned - against the
523        // wall clock this is the exact case a one-second drift inverts.
524        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        // Production inserts no `StallClock`. The age here is far enough past
536        // the limit that no drift between the two reads can change the verdict.
537        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        // Far past the grace period: waiting behind long inferences is fine.
563        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        // Test worlds and `lev run` don't insert `StallTimeout`.
592        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        // A paused run is not stalled - it is stopped on purpose, and resuming
616        // it must not find it failed.
617        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        // `StageIoBuffer` is optional (test worlds, `lev run`).
632        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        // The agent left the ready state for some unrelated reason (a stuck
650        // edge, an iteration cap) and came back. It must not be judged on a
651        // wait it was not actually doing.
652        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        // A continuing stall keeps its start time, so the age is the whole wait.
681        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        // A different reason is a different problem: it gets its own grace.
687        let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
688        assert_eq!(changed.since, 120);
689        assert_eq!(changed.reason, StallReason::ProviderMissing);
690        // So does a stall that went unobserved long enough to have ended.
691        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        // Failing `PoolFull` would be failing backpressure.
712        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        // The end state of issue #201: nothing left to fail over to. Waiting
720        // for ever reads as a healthy run that is going nowhere.
721        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        // Issue #413: exhausted credits are an account state the operator can
733        // fix, so the watchdog pauses the run instead of ending it. The
734        // `ReadyToInfer` marker stays, so a resume re-dispatches the same
735        // inference.
736        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        // `StageIoBuffer` is optional on the query, so the pause has to land
775        // even when there is no stage log to explain it in.
776        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    /// Each way a provider can go out of service gets its own answer, because
807    /// each has its own fix: top up, replace the key, or check the plan. A
808    /// client that had only "unavailable" would have to send everyone to the
809    /// same screen and hope.
810    #[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                // Nothing anyone can point at: neither the account nor the key
831                // is known to be wrong, so neither screen is the right one.
832                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    /// `StageIoBuffer` is optional on the query, so the unattended failure has
858    /// to land even when there is no stage log to explain it in.
859    #[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    /// A provider that was never configured is a different fix again, and the
880    /// one case that does not go through the breaker at all.
881    #[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        // Unlike a missing provider, this one can come back on its own once
899        // the cooldown lets a probe through, so the grace period matters.
900        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        // `PoolFull` never reaches the watchdog, but the arm must still answer.
919        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}