Skip to main content

leviath_runtime/pipeline/
wedge.rs

1//! The wedge watchdog: fail a run that no system can ever look at again.
2//!
3//! Every non-terminal agent rests between ticks holding exactly one phase
4//! marker, and each marker is a claim some system has on it: `ReadyToInfer` is
5//! dispatch's, `AwaitingInference` is the inference collector's, `FanOutWaiting`
6//! is the fan-out collector's, and so on. The marker is what makes the agent
7//! reachable. An agent that is non-terminal and holds *none* of them is in a
8//! state no query matches, so nothing will ever touch it again, and it stays
9//! `running` in `meta.json` for the life of the daemon.
10//!
11//! That is not hypothetical. `PipelineWorld` already logs "a pipeline system
12//! panicked outside any agent's scope; the daemon survived (an agent may be
13//! wedged - cancel it via `lev cancel <run-id>`)": the runtime knows it can
14//! strand a run and asks a person to clean up. Issue #202 is what happens when
15//! the "person" is an unattended harness. It counted the run as occupying a slot
16//! for ever, and its factory ran down to zero free slots over a few hours.
17//!
18//! ## Why this cannot produce a false positive
19//!
20//! The trigger is structural, not temporal. It is not "this run looks old" -
21//! that is the shape of the misdiagnosis in issues #184, #189, #190 and #197,
22//! where a fresh `updated_at`, a bare `waiting`, and a `pid` field each got read
23//! as evidence they were never able to give. It is "the pipeline's own
24//! invariants say this state cannot exist", and the timeout only absorbs the
25//! transient windows inside a tick.
26//!
27//! Those invariants hold on both sides. Every site that removes a phase marker
28//! either inserts a successor or sets a terminal status, and `spawn_agent_seeded`
29//! always lands `Active + ReadyToInfer`, so no ordinary path arrives here.
30//!
31//! What is *not* touched, and why:
32//!
33//! - A long inference holds `AwaitingInference` and `InFlightWork`, and is
34//!   bounded twice over besides (the provider's job timeout, and the lane
35//!   supervisor that turns a dead task into an ordinary error outcome). A
36//!   fifteen-minute call is never a candidate.
37//! - A full inference pool leaves the agent `ReadyToInfer` with a
38//!   [`DispatchStall`](super::DispatchStall). That is backpressure working as
39//!   designed, and issue #190's watchdog already declines to fail it.
40//! - A tool batch holds `AwaitingTools` and is deliberately unbounded: it may
41//!   park off-lane on a tool approval, an `ask_user`, or a `wait_for_agent` that
42//!   ends only when some other run does. Any clock-based rule would have to
43//!   guess a bound here. This one does not have to.
44//! - A run blocked on a person holds an interaction marker and is left entirely
45//!   alone. That is issue #204's territory, and deliberately so: killing a run
46//!   somebody is about to answer is a worse failure than leaking a slot, and two
47//!   timeouts racing over one status is how #184 happened.
48//! - `Paused` is skipped before the clock is even read, and loses any record it
49//!   was carrying, so resuming never finds a run the watchdog had started
50//!   counting.
51//!
52//! One residual class this does not cover, stated plainly: an agent that *holds*
53//! a marker but is missing some component the matching system's query also
54//! requires, so that query never sees it. No production path builds an agent
55//! that way today. Covering it would need exactly the "looks old" reasoning the
56//! rest of this module exists to avoid, so it is left uncovered rather than
57//! guessed at.
58//!
59//! ## It composes upward
60//!
61//! Failing a wedged child is enough to free its parent. The fan-out collector
62//! already counts an `Error` worker as finished, and a `requires_children` gate
63//! releases on any terminal child, so there is no parent case to special-case.
64
65use super::*;
66
67/// An agent found in a state no system can reach, and when it was first seen
68/// that way.
69///
70/// One field, unlike [`DispatchStall`](super::DispatchStall), which also carries
71/// a freshness stamp. That record is written by the dispatch systems and read by
72/// a different one, so it has to cope with its writer going away. This one has a
73/// single owner: the watchdog inserts it when the condition holds, keeps the
74/// original `since` while it keeps holding, and removes it the moment the agent
75/// becomes reachable again. There is nothing to go stale.
76#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Wedged {
78    /// Unix seconds when the agent was first seen unreachable.
79    pub since: i64,
80}
81
82/// How long an agent may sit unreachable before the run is failed.
83///
84/// A world resource rather than a constant because the daemon serves it from
85/// `[limits] wedge_timeout_secs`. Zero disables the watchdog.
86#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
87pub struct WedgeTimeout(pub u64);
88
89impl Default for WedgeTimeout {
90    fn default() -> Self {
91        Self(DEFAULT_WEDGE_TIMEOUT_SECS)
92    }
93}
94
95/// Default grace period before an unreachable agent fails its run: `0`, meaning
96/// the watchdog is off unless an operator turns it on.
97///
98/// Off by default because this fails runs, and a daemon that starts killing work
99/// after an upgrade nobody asked for is a worse outcome than the leak it
100/// prevents. `300` is the value to set once you want it: five minutes is ten of
101/// the daemon's thirty-second re-drives, the same span `dead_cycles_before_relief`
102/// already treats as "long enough to act on".
103pub const DEFAULT_WEDGE_TIMEOUT_SECS: u64 = 0;
104
105/// Query filter: the agent holds nothing that will cause any system to look at
106/// it again.
107///
108/// Every entry is a component whose *presence* means some system has this agent
109/// queued. This is the load-bearing list in the module, and the one thing here
110/// that needs maintaining: **a new phase marker must be added here**, or an agent
111/// resting on it will be mistaken for an unreachable one. The table-driven test
112/// at the bottom of this file is what catches that.
113///
114/// Note that [`PipelineWorld::fingerprint`](crate::world::PipelineWorld) counts a
115/// subset of these markers for a different purpose (deciding whether a tick
116/// changed anything). The two lists answer different questions and are not
117/// interchangeable.
118///
119/// Components that are per-agent *data* rather than a claim on the agent -
120/// `StageCursor`, `StageProgress`, `DynamicTools`, the auto-approve markers, and
121/// `DispatchStall` itself - are deliberately absent. Their presence says nothing
122/// about whether anything is going to run.
123pub type Unreachable = (
124    (
125        Without<ReadyToInfer>,
126        Without<AwaitingInference>,
127        Without<ProcessResponse>,
128        Without<ReadyForTools>,
129        Without<AwaitingTools>,
130        Without<ReadyForTransition>,
131        Without<ResolveTransition>,
132        Without<ToolsNeedRefresh>,
133        Without<StageJustEntered>,
134        Without<AwaitingTransitionChoice>,
135        Without<AwaitingTransitionResponse>,
136        Without<WaitingForChildren>,
137    ),
138    (
139        Without<AwaitingCompaction>,
140        Without<PendingEdgeCompact>,
141        Without<crate::context_transform::AwaitingContentSummary>,
142        Without<crate::context_transform::PendingContentSummary>,
143        Without<crate::title::PendingTitle>,
144        Without<crate::title::AwaitingTitle>,
145        Without<crate::components::AwaitingInteraction>,
146        Without<crate::gate_prompt::AwaitingGatePrompt>,
147        Without<crate::gate_prompt::GateResolved>,
148        Without<crate::interaction_points::ReadyForInteractionPoint>,
149        Without<crate::interaction_points::AwaitingInteractionPoint>,
150        Without<crate::fanout::FanOutWaiting>,
151    ),
152    (
153        Without<InFlightWork>,
154        Without<crate::tick_scope::PanickedInParallel>,
155    ),
156);
157
158/// Wedge watchdog: fail any non-terminal agent that has been unreachable for
159/// longer than [`WedgeTimeout`].
160///
161/// See the module documentation for why this is safe. In short: an agent matches
162/// [`Unreachable`] only in a state the rest of the pipeline guarantees it never
163/// leaves an agent in, so anything that matches is already lost.
164#[allow(clippy::type_complexity)]
165pub fn fail_wedged_runs(
166    mut agents: Query<
167        (
168            Entity,
169            Option<&Wedged>,
170            &mut AgentState,
171            Option<&mut StageIoBuffer>,
172        ),
173        Unreachable,
174    >,
175    timeout: Option<Res<WedgeTimeout>>,
176    mut commands: Commands,
177) {
178    crate::tick_scope::clear();
179    let limit = timeout.map(|t| t.0).unwrap_or(DEFAULT_WEDGE_TIMEOUT_SECS);
180    if limit == 0 {
181        return; // watchdog disabled
182    }
183    let now = chrono::Utc::now().timestamp();
184    for (entity, wedged, mut state, buffer) in agents.iter_mut() {
185        crate::tick_scope::enter(entity);
186        // A terminal agent is the goal, not a problem, and the host reaps it. A
187        // paused one is stopped because somebody stopped it, and must come back
188        // with no clock already running against it.
189        if is_terminal(&state.status) || state.status == AgentStatus::Paused {
190            if wedged.is_some() {
191                commands.entity(entity).remove::<Wedged>();
192            }
193            continue;
194        }
195        let since = wedged.map(|w| w.since).unwrap_or(now);
196        if now.saturating_sub(since) < limit as i64 {
197            // Inside the grace period: record it (keeping the original start) so
198            // the next tick measures the whole wait rather than restarting.
199            commands.entity(entity).insert(Wedged { since });
200            continue;
201        }
202        let waited = now.saturating_sub(since);
203        let message = format!(
204            "run stopped being driven in stage '{}': {waited}s with nothing in flight and \
205             no marker any system acts on, so it can never move again. Failing it releases \
206             the capacity it was holding. This is a bug in Leviath - please report it with \
207             the daemon log",
208            state.current_stage
209        );
210        tracing::error!(
211            stage = %state.current_stage,
212            wedged_secs = waited,
213            "failing a run that nothing can drive"
214        );
215        if let Some(mut buffer) = buffer {
216            buffer.logs.push((0, format!("[wedged] {message}")));
217        }
218        state.status = AgentStatus::Error { message };
219        commands.entity(entity).remove::<Wedged>();
220    }
221}
222
223/// Whether an agent has stopped for good. Terminal agents are left to the host,
224/// which reaps them once their state has been persisted and reported.
225fn is_terminal(status: &AgentStatus) -> bool {
226    matches!(
227        status,
228        AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
229    )
230}
231
232#[cfg(test)]
233mod tests;