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/// Default grace period before an unresolvable stall fails its run.
118///
119/// Long enough that a provider arriving late - a `.rhai` script dropped into the
120/// providers directory resolves on the next dispatch - still rescues the run,
121/// short enough that an operator watching `lev ps` gets an answer rather than a
122/// run that claims to be working.
123pub const DEFAULT_STALL_TIMEOUT_SECS: u64 = 60;
124
125/// Record that an agent's dispatch declined for `reason`, preserving the start
126/// time of an ongoing stall of the same kind.
127///
128/// The clock restarts unless this continues a stall that is both the *same
129/// kind* and still fresh. A changed reason is a different problem and deserves
130/// its own grace period rather than inheriting the age of the old one; a stale
131/// record describes a wait that already ended (see
132/// [`STALL_FRESHNESS_SECS`]).
133pub(crate) fn note_stall(
134    existing: Option<&DispatchStall>,
135    reason: StallReason,
136    now: i64,
137) -> DispatchStall {
138    let since = match existing {
139        Some(prev)
140            if prev.reason == reason
141                && now.saturating_sub(prev.last_seen) <= STALL_FRESHNESS_SECS =>
142        {
143            prev.since
144        }
145        _ => now,
146    };
147    DispatchStall {
148        since,
149        last_seen: now,
150        reason,
151    }
152}
153
154/// Dispatch-stall watchdog: fail any agent whose dispatch has been declining for
155/// an unresolvable reason longer than [`StallTimeout`].
156///
157/// This is the backstop under issue #190. A stage pointing at a provider that
158/// isn't registered leaves the agent `Active` and `ReadyToInfer` with nothing in
159/// flight - so from the outside it reads as a healthy running run, for ever, at
160/// iteration 0. The daemon now re-ticks on a heartbeat, which makes the retry
161/// real, but retrying a provider that will never exist just means failing
162/// quietly for ever instead of loudly once.
163///
164/// Only [`StallReason::ProviderMissing`] is failed. A full pool is deliberately
165/// exempt: it is what backpressure is supposed to look like, and a run waiting
166/// its turn behind seven long inferences is working exactly as intended.
167///
168/// What makes this safe from false positives is *which* agents can carry a
169/// [`DispatchStall`] at all. Only a dispatch system that declined attaches one,
170/// and dispatching removes it - so an agent holding one has nothing
171/// outstanding. A fifteen-minute inference is `AwaitingInference` with no stall
172/// record, and is never a candidate here.
173#[allow(clippy::type_complexity)]
174pub fn fail_stalled_dispatch(
175    mut agents: Query<(
176        Entity,
177        &DispatchStall,
178        &StageInference,
179        &mut AgentState,
180        Option<&mut StageIoBuffer>,
181    )>,
182    timeout: Option<Res<StallTimeout>>,
183    mut commands: Commands,
184) {
185    crate::tick_scope::clear();
186    let limit = timeout.map(|t| t.0).unwrap_or(DEFAULT_STALL_TIMEOUT_SECS);
187    if limit == 0 {
188        return; // watchdog disabled
189    }
190    let now = chrono::Utc::now().timestamp();
191    for (entity, stall, si, mut state, buffer) in agents.iter_mut() {
192        crate::tick_scope::enter(entity);
193        if state.status != AgentStatus::Active || !stall.reason.needs_a_person() {
194            continue;
195        }
196        if now.saturating_sub(stall.last_seen) > STALL_FRESHNESS_SECS {
197            // The wait this describes has ended; nothing to act on.
198            tracing::debug!(
199                reason = stall.reason.label(),
200                "discarding a dispatch stall that stopped being refreshed"
201            );
202            commands.entity(entity).remove::<DispatchStall>();
203            continue;
204        }
205        if now.saturating_sub(stall.since) < limit as i64 {
206            continue; // still inside the grace period
207        }
208        let message = stall.reason.give_up_message(&si.provider_name);
209        tracing::error!(
210            provider = %si.provider_name,
211            reason = stall.reason.label(),
212            stalled_secs = now.saturating_sub(stall.since),
213            "failing a run whose provider will never resolve"
214        );
215        if let Some(mut buffer) = buffer {
216            buffer.logs.push((0, format!("[stalled] {message}")));
217        }
218        state.status = AgentStatus::Error { message };
219        commands
220            .entity(entity)
221            .remove::<ReadyToInfer>()
222            .remove::<DispatchStall>();
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn agent_state() -> AgentState {
231        AgentState {
232            agent_id: "a".to_string(),
233            current_stage: "s".to_string(),
234            iteration: 0,
235            status: AgentStatus::Active,
236            spawned_children_ids: vec![],
237            pending_wait: None,
238            accepts_messages: true,
239        }
240    }
241
242    fn stage_inference() -> StageInference {
243        StageInference {
244            provider_name: "ghost".to_string(),
245            model: "m".to_string(),
246            tools: vec![],
247            tool_filter: None,
248            fallbacks: Vec::new(),
249        }
250    }
251
252    /// A stall that started `age` seconds ago and is still being refreshed.
253    fn stalled_for(reason: StallReason, age: i64) -> DispatchStall {
254        let now = chrono::Utc::now().timestamp();
255        DispatchStall {
256            since: now - age,
257            last_seen: now,
258            reason,
259        }
260    }
261
262    /// Spawn an agent that has been stalled for `age` seconds for `reason`.
263    fn spawn_stalled(world: &mut World, reason: StallReason, age: i64) -> Entity {
264        world
265            .spawn((
266                agent_state(),
267                stage_inference(),
268                stalled_for(reason, age),
269                StageIoBuffer::default(),
270                ReadyToInfer,
271            ))
272            .id()
273    }
274
275    fn run(world: &mut World) {
276        let mut schedule = Schedule::default();
277        schedule.add_systems(fail_stalled_dispatch);
278        schedule.run(world);
279    }
280
281    #[test]
282    fn a_provider_that_will_never_resolve_fails_the_run() {
283        let mut world = World::new();
284        world.insert_resource(StallTimeout(60));
285        let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 61);
286
287        run(&mut world);
288
289        let status = &world.get::<AgentState>(e).unwrap().status;
290        assert!(
291            matches!(status, AgentStatus::Error { message }
292                if message.contains("ghost") && message.contains("not configured")),
293            "got: {status:?}"
294        );
295        // Taken out of dispatch, and the stall record is spent.
296        assert!(world.get::<ReadyToInfer>(e).is_none());
297        assert!(world.get::<DispatchStall>(e).is_none());
298        // The operator sees why in the stage log the dashboard renders.
299        let logs = &world.get::<StageIoBuffer>(e).unwrap().logs;
300        assert!(
301            logs.iter().any(|(_, line)| line.starts_with("[stalled]")),
302            "expected a [stalled] log line, got: {logs:?}"
303        );
304    }
305
306    #[test]
307    fn a_stall_inside_the_grace_period_is_left_alone() {
308        let mut world = World::new();
309        world.insert_resource(StallTimeout(60));
310        let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 59);
311
312        run(&mut world);
313
314        assert_eq!(
315            world.get::<AgentState>(e).unwrap().status,
316            AgentStatus::Active
317        );
318        assert!(world.get::<ReadyToInfer>(e).is_some());
319    }
320
321    #[test]
322    fn a_full_pool_is_backpressure_and_is_never_failed() {
323        let mut world = World::new();
324        world.insert_resource(StallTimeout(60));
325        // Far past the grace period: waiting behind long inferences is fine.
326        let e = spawn_stalled(&mut world, StallReason::PoolFull, 10_000);
327
328        run(&mut world);
329
330        assert_eq!(
331            world.get::<AgentState>(e).unwrap().status,
332            AgentStatus::Active
333        );
334        assert!(world.get::<ReadyToInfer>(e).is_some());
335        assert!(world.get::<DispatchStall>(e).is_some());
336    }
337
338    #[test]
339    fn a_zero_timeout_disables_the_watchdog() {
340        let mut world = World::new();
341        world.insert_resource(StallTimeout(0));
342        let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
343
344        run(&mut world);
345
346        assert_eq!(
347            world.get::<AgentState>(e).unwrap().status,
348            AgentStatus::Active
349        );
350    }
351
352    #[test]
353    fn a_world_without_the_resource_uses_the_default_timeout() {
354        // Test worlds and `lev run` don't insert `StallTimeout`.
355        let mut world = World::new();
356        let inside = spawn_stalled(
357            &mut world,
358            StallReason::ProviderMissing,
359            DEFAULT_STALL_TIMEOUT_SECS as i64 - 1,
360        );
361        let past = spawn_stalled(
362            &mut world,
363            StallReason::ProviderMissing,
364            DEFAULT_STALL_TIMEOUT_SECS as i64 + 1,
365        );
366
367        run(&mut world);
368
369        assert_eq!(
370            world.get::<AgentState>(inside).unwrap().status,
371            AgentStatus::Active
372        );
373        let status = &world.get::<AgentState>(past).unwrap().status;
374        assert!(
375            matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
376            "got: {status:?}"
377        );
378    }
379
380    #[test]
381    fn a_non_active_agent_is_left_to_its_own_status() {
382        // A paused run is not stalled - it is stopped on purpose, and resuming
383        // it must not find it failed.
384        let mut world = World::new();
385        let e = spawn_stalled(&mut world, StallReason::ProviderMissing, 10_000);
386        world.get_mut::<AgentState>(e).unwrap().status = AgentStatus::Paused;
387
388        run(&mut world);
389
390        assert_eq!(
391            world.get::<AgentState>(e).unwrap().status,
392            AgentStatus::Paused
393        );
394    }
395
396    #[test]
397    fn an_agent_without_a_stage_log_still_fails() {
398        // `StageIoBuffer` is optional (test worlds, `lev run`).
399        let mut world = World::new();
400        let e = world
401            .spawn((
402                agent_state(),
403                stage_inference(),
404                stalled_for(StallReason::ProviderMissing, 10_000),
405                ReadyToInfer,
406            ))
407            .id();
408
409        run(&mut world);
410
411        let status = &world.get::<AgentState>(e).unwrap().status;
412        assert!(
413            matches!(status, AgentStatus::Error { message } if message.contains("ghost")),
414            "got: {status:?}"
415        );
416    }
417
418    #[test]
419    fn a_stall_that_stopped_being_refreshed_is_discarded() {
420        // The agent left the ready state for some unrelated reason (a stuck
421        // edge, an iteration cap) and came back. It must not be judged on a
422        // wait it was not actually doing.
423        let mut world = World::new();
424        let now = chrono::Utc::now().timestamp();
425        let e = world
426            .spawn((
427                agent_state(),
428                stage_inference(),
429                DispatchStall {
430                    since: now - 10_000,
431                    last_seen: now - STALL_FRESHNESS_SECS - 1,
432                    reason: StallReason::ProviderMissing,
433                },
434                ReadyToInfer,
435            ))
436            .id();
437
438        run(&mut world);
439
440        assert_eq!(
441            world.get::<AgentState>(e).unwrap().status,
442            AgentStatus::Active
443        );
444        assert!(
445            world.get::<DispatchStall>(e).is_none(),
446            "the spent record is cleared rather than left to mislead"
447        );
448    }
449
450    #[test]
451    fn note_stall_continues_a_live_stall_and_restarts_otherwise() {
452        // A continuing stall keeps its start time, so the age is the whole wait.
453        let first = note_stall(None, StallReason::PoolFull, 100);
454        assert_eq!((first.since, first.last_seen), (100, 100));
455        let still = note_stall(Some(&first), StallReason::PoolFull, 120);
456        assert_eq!(still.since, 100, "an ongoing stall keeps its clock");
457        assert_eq!(still.last_seen, 120, "but records that it is still live");
458        // A different reason is a different problem: it gets its own grace.
459        let changed = note_stall(Some(&first), StallReason::ProviderMissing, 120);
460        assert_eq!(changed.since, 120);
461        assert_eq!(changed.reason, StallReason::ProviderMissing);
462        // So does a stall that went unobserved long enough to have ended.
463        let resumed = note_stall(
464            Some(&first),
465            StallReason::PoolFull,
466            100 + STALL_FRESHNESS_SECS + 1,
467        );
468        assert_eq!(resumed.since, 100 + STALL_FRESHNESS_SECS + 1);
469    }
470
471    #[test]
472    fn stall_reasons_have_labels() {
473        assert_eq!(StallReason::ProviderMissing.label(), "provider-missing");
474        assert_eq!(StallReason::PoolFull.label(), "pool-full");
475        assert_eq!(
476            StallReason::ProviderCircuitOpen.label(),
477            "provider-circuit-open"
478        );
479    }
480
481    #[test]
482    fn only_the_reasons_a_person_must_fix_are_failed() {
483        // Failing `PoolFull` would be failing backpressure.
484        assert!(StallReason::ProviderMissing.needs_a_person());
485        assert!(StallReason::ProviderCircuitOpen.needs_a_person());
486        assert!(!StallReason::PoolFull.needs_a_person());
487    }
488
489    #[test]
490    fn a_run_with_every_provider_out_of_service_is_failed_not_left_running() {
491        // The end state of issue #201: nothing left to fail over to. Waiting
492        // for ever reads as a healthy run that is going nowhere.
493        let mut world = World::new();
494        world.insert_resource(StallTimeout(60));
495        let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 61);
496
497        run(&mut world);
498
499        let status = &world.get::<AgentState>(e).unwrap().status;
500        assert!(
501            matches!(status, AgentStatus::Error { message }
502                if message.contains("out of service") && message.contains("fallback_order")),
503            "got: {status:?}"
504        );
505        assert!(world.get::<ReadyToInfer>(e).is_none());
506    }
507
508    #[test]
509    fn an_open_circuit_inside_the_grace_period_gets_its_chance_to_recover() {
510        // Unlike a missing provider, this one can come back on its own once
511        // the cooldown lets a probe through, so the grace period matters.
512        let mut world = World::new();
513        world.insert_resource(StallTimeout(60));
514        let e = spawn_stalled(&mut world, StallReason::ProviderCircuitOpen, 59);
515
516        run(&mut world);
517
518        assert_eq!(
519            world.get::<AgentState>(e).unwrap().status,
520            AgentStatus::Active
521        );
522    }
523
524    #[test]
525    fn the_give_up_message_names_the_provider() {
526        let missing = StallReason::ProviderMissing.give_up_message("ghost");
527        assert!(missing.contains("ghost") && missing.contains("not configured"));
528        let open = StallReason::ProviderCircuitOpen.give_up_message("openrouter");
529        assert!(open.contains("openrouter") && open.contains("out of service"));
530        // `PoolFull` never reaches the watchdog, but the arm must still answer.
531        assert!(StallReason::PoolFull.give_up_message("x").contains("x"));
532    }
533
534    #[test]
535    fn the_default_timeout_is_the_documented_grace_period() {
536        assert_eq!(StallTimeout::default().0, DEFAULT_STALL_TIMEOUT_SECS);
537    }
538}