Skip to main content

leviath_runtime/host/
health.rs

1//! Whether the daemon is keeping up, and what to do when it is not.
2//!
3//! Three questions that only make sense together: how often the world is being
4//! re-driven, whether a lane is wedged badly enough to need relief, and what
5//! `lev daemon status` should say about it. All three read the same lane
6//! snapshot, which is why they are one module rather than three.
7
8use super::*;
9
10impl WorldHost {
11    /// Take stock once per safety re-drive: has anything moved, and are the lanes
12    /// full? Updates the dead-cycle count and reports.
13    ///
14    /// A *dead cycle* is a whole re-drive interval in which some lane was at
15    /// capacity with work queued behind it and no run observably moved. Both
16    /// halves matter. Pressure on its own is just a busy daemon. Stillness on its
17    /// own is an idle one, or one agent in a long inference with nobody waiting.
18    /// Together they are the shape issue #191 reported: work to do, no capacity to
19    /// do it with, and no sign of that ever changing.
20    pub(super) fn observe_redrive(&mut self) {
21        let snapshot = self.world.lane_snapshot();
22        let progress = self.progress_fingerprint();
23        let went_nowhere = snapshot.is_under_pressure() && self.last_progress == Some(progress);
24        self.last_progress = Some(progress);
25        self.dead_cycles = match went_nowhere {
26            true => self.dead_cycles.saturating_add(1),
27            false => 0,
28        };
29        self.log_lane_pressure(&snapshot);
30        let relief = self.relieve_if_wedged(&snapshot);
31        self.decay_relief_if_healthy(&snapshot);
32        self.observe_lanes(&snapshot, relief);
33    }
34
35    /// The relief valve's give-back half: once the lane has been demonstrably
36    /// healthy for [`HEALTHY_CYCLES_BEFORE_DECAY`] consecutive re-drives,
37    /// reclaim one granted permit per further healthy cycle until the lane is
38    /// back at its configured width.
39    ///
40    /// The guards are what keep this on the safe side of the wedge detection
41    /// that granted the relief in the first place (issue #191): nothing is
42    /// reclaimed while `dead_cycles` is non-zero (a wedge may be forming),
43    /// nothing is reclaimed while the extra capacity is in use (`narrow` only
44    /// takes *idle* permits), and the width can never drop below what the
45    /// config asked for, because only permits this valve granted are counted.
46    pub(super) fn decay_relief_if_healthy(&mut self, snapshot: &LaneSnapshot) {
47        if self.relief_granted == 0 {
48            self.healthy_cycles = 0;
49            return;
50        }
51        let healthy = self.dead_cycles == 0 && snapshot.tools_queued == 0;
52        self.healthy_cycles = match healthy {
53            true => self.healthy_cycles.saturating_add(1),
54            false => 0,
55        };
56        if self.healthy_cycles < HEALTHY_CYCLES_BEFORE_DECAY {
57            return;
58        }
59        let narrowed = self.world.narrow_tool_lane(1);
60        if narrowed > 0 {
61            self.relief_granted -= narrowed;
62            tracing::info!(
63                narrowed,
64                relief_granted = self.relief_granted,
65                "the jam is over; reclaiming relief capacity from the tool lane"
66            );
67        }
68    }
69
70    /// Widen the tool lane if the daemon has been going nowhere long enough, and
71    /// report how much capacity was added.
72    ///
73    /// Deliberately additive. The tempting reading of "force-reclaim stuck
74    /// slots" is to kill whatever is holding them, and that is the wrong move
75    /// here: a run parked on an `ask_user` is doing exactly what it should, and
76    /// an operator who mistook `waiting` for `stuck` and started killing healthy
77    /// runs is the story behind issue #184. Handing out more capacity unwedges a
78    /// jammed lane without having to be right about which run deserves to die.
79    ///
80    /// Only the tool lane is widened. A full inference pool is a deliberate cap
81    /// on requests in flight to a provider, and forcing extra ones past it would
82    /// trade a wedge for a rate limit.
83    ///
84    /// Capped at one extra lane's worth over the daemon's life. If that is not
85    /// enough, the problem is not capacity and more of it will not help.
86    pub(super) fn relieve_if_wedged(&mut self, snapshot: &LaneSnapshot) -> usize {
87        let threshold = self.dead_cycles_before_relief;
88        if threshold == 0 || self.dead_cycles < threshold || !snapshot.tools_saturated {
89            return 0;
90        }
91        // The snapshot's width already includes everything granted so far, so
92        // back it out to get the lane's configured width - the budget.
93        let configured = snapshot.tools_workers.saturating_sub(self.relief_granted);
94        let remaining = configured.saturating_sub(self.relief_granted);
95        let granted = self
96            .world
97            .relieve_tool_lane(remaining.min(snapshot.tools_queued));
98        self.relief_granted += granted;
99        tracing::error!(
100            dead_cycles = self.dead_cycles,
101            granted,
102            relief_granted = self.relief_granted,
103            tools_queued = snapshot.tools_queued,
104            tools_parked = snapshot.tools_parked,
105            "the tool lane has not drained in {} cycles; widening it by {granted}",
106            self.dead_cycles
107        );
108        // Give the widened lane a fresh interval to show whether it helped,
109        // rather than granting again on the very next re-drive.
110        self.dead_cycles = 0;
111        granted
112    }
113
114    /// How many dead cycles the daemon tolerates before widening the tool lane.
115    /// `0` disables relief; detection and reporting are unaffected. Served from
116    /// `[limits] dead_cycles_before_relief`.
117    pub fn set_dead_cycles_before_relief(&mut self, cycles: u32) {
118        self.dead_cycles_before_relief = cycles;
119    }
120
121    /// Hand one daemon-wide health sample to the telemetry sink.
122    ///
123    /// `relief` is the capacity granted on this sample, which is a per-sample
124    /// figure rather than a running total: the sink accumulates it.
125    pub(super) fn observe_lanes(&self, snapshot: &LaneSnapshot, relief: usize) {
126        // Every `PipelineWorld::new` installs the sink resource (a no-op one
127        // unless a host replaced it), so this is a hard invariant rather than a
128        // branch - the same reasoning as `set_exact_token_counting`.
129        self.world
130            .world()
131            .resource::<crate::telemetry::Telemetry>()
132            .0
133            .observe_lanes(leviath_core::telemetry::LaneHealth {
134                agents_active: snapshot.agents.active,
135                agents_waiting: snapshot.agents.waiting,
136                tools_busy: snapshot.tools_busy,
137                tools_queued: snapshot.tools_queued,
138                tools_parked: snapshot.tools_parked,
139                tools_workers: snapshot.tools_workers,
140                dead_cycles: self.dead_cycles,
141                relief_granted: relief,
142            });
143        // Sampled on the same tick, and unconditionally: a collector needs the
144        // empty sample to see that a provider came *back*, not just that it
145        // went away (issue #201).
146        let down: Vec<leviath_core::telemetry::ProviderHealth> = self
147            .world
148            .open_circuits()
149            .into_iter()
150            .map(|c| leviath_core::telemetry::ProviderHealth {
151                provider: c.provider,
152                reason: c.reason.label().to_string(),
153                consecutive_failures: c.consecutive_failures,
154                retry_in_secs: c.retry_in_secs,
155            })
156            .collect();
157        self.world
158            .world()
159            .resource::<crate::telemetry::Telemetry>()
160            .0
161            .observe_providers(&down);
162    }
163
164    /// The daemon's own health: lane occupancy plus the dead-cycle count.
165    ///
166    /// Served alongside every run listing, because "is this run stuck" and "is
167    /// the daemon stuck" are answered by different numbers and an operator
168    /// looking at one wants the other in the same breath.
169    pub fn health(&self) -> DaemonHealth {
170        let snapshot = self.world.lane_snapshot();
171        DaemonHealth {
172            agents: snapshot.agents,
173            inference: snapshot.inference,
174            tools_busy: snapshot.tools_busy,
175            tools_queued: snapshot.tools_queued,
176            tools_parked: snapshot.tools_parked,
177            tools_workers: snapshot.tools_workers,
178            dead_cycles: self.dead_cycles,
179            relief_granted: self.relief_granted,
180            redrive_secs: self.redrive.as_secs(),
181            providers_down: self.world.open_circuits(),
182        }
183    }
184
185    /// A number that changes exactly when some run observably moves.
186    ///
187    /// Derived from the per-run snapshots `emit_events` already keeps to decide
188    /// what to broadcast, so an unchanged fingerprint means "nothing happened
189    /// that anyone watching would have been told about" - not merely "no event
190    /// was sent", which would also be true of a daemon nobody is subscribed to.
191    ///
192    /// Summed rather than fed through one hasher because a `HashMap` has no
193    /// iteration order to depend on. Every field it covers is either monotonic or
194    /// hashed, so two different worlds colliding takes a deliberate effort.
195    pub(super) fn progress_fingerprint(&self) -> u64 {
196        use std::hash::{Hash, Hasher};
197        let mut total = self.emitted.len() as u64;
198        for entry in &self.emitted {
199            let mut hasher = std::collections::hash_map::DefaultHasher::new();
200            entry.hash(&mut hasher);
201            total = total.wrapping_add(hasher.finish());
202        }
203        total
204    }
205
206    /// Report what the lanes are holding.
207    ///
208    /// The daemon otherwise logs nothing per tick, by design - observation goes
209    /// through the telemetry sink. But a wedged daemon emits no telemetry either,
210    /// precisely because nothing is happening, so "frozen for hours" left no
211    /// trace at all (issue #189). This is the one periodic line that can answer
212    /// "is anything running, and what is it queued behind?".
213    ///
214    /// Quiet by default: `warn` once the daemon has been going nowhere, `info`
215    /// while a lane is merely at capacity, `debug` otherwise, so an idle daemon
216    /// says nothing above `debug`.
217    pub(super) fn log_lane_pressure(&self, snapshot: &LaneSnapshot) {
218        let agents = snapshot.agents.to_string();
219        let inference = snapshot.inference_summary();
220        if self.dead_cycles > 0 {
221            tracing::warn!(
222                dead_cycles = self.dead_cycles,
223                agents = %agents,
224                inference = %inference,
225                tools_busy = snapshot.tools_busy,
226                tools_workers = snapshot.tools_workers,
227                tools_queued = snapshot.tools_queued,
228                tools_parked = snapshot.tools_parked,
229                "no progress while the lanes are full"
230            );
231        } else if snapshot.is_under_pressure() {
232            tracing::info!(
233                agents = %agents,
234                inference = %inference,
235                tools_busy = snapshot.tools_busy,
236                tools_workers = snapshot.tools_workers,
237                tools_queued = snapshot.tools_queued,
238                tools_parked = snapshot.tools_parked,
239                "lane heartbeat: at capacity with work queued"
240            );
241        } else {
242            tracing::debug!(
243                agents = %agents,
244                inference = %inference,
245                tools_busy = snapshot.tools_busy,
246                tools_workers = snapshot.tools_workers,
247                tools_queued = snapshot.tools_queued,
248                tools_parked = snapshot.tools_parked,
249                "lane heartbeat"
250            );
251        }
252    }
253
254    /// Override how often [`Self::serve`] re-drives the world with no wake.
255    ///
256    /// Exists so tests don't have to wait out the 30-second default; the daemon
257    /// uses it as-is.
258    pub fn set_redrive_interval(&mut self, every: Duration) {
259        self.redrive = every;
260    }
261}