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}