Skip to content

Commit 1bce08e

Browse files
feat(recovery): add self-healing watchdog primitives
Add backend health state tracking, evidence fingerprints, recovery budgets, parked-state persistence, and queue-healing policy primitives. Wire backend health cooldown decisions into the recovery engine and allow triage-scan to apply structured, retry-safe queue healing for blocked duplicate deadlocks. Update the watchdog loop runbook to move common diagnosis and recovery behavior from prompt-side inference into watcher-owned telemetry and bounded recovery state.
1 parent 72243b5 commit 1bce08e

22 files changed

Lines changed: 1447 additions & 0 deletions

File tree

docs/operator/watchdog_loop.md

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,240 @@ Healthy direction:
7474

7575
---
7676

77+
## Runtime health model
78+
79+
Backend stability is first-class platform state, not a conclusion the loop
80+
reconstructs from log tails. Watchers and runtime services should write or
81+
surface structured health records equivalent to:
82+
83+
```yaml
84+
backend_health:
85+
kodo:
86+
state: unstable
87+
failure_count: 2
88+
last_success_at: null
89+
last_failure:
90+
signature: signal:SIGKILL
91+
signal: SIGKILL
92+
exit_code: null
93+
cooldown_until: "..."
94+
safe_retry_after: "..."
95+
recovery_strategy: reduce_pressure
96+
```
97+
98+
Allowed states are `unknown`, `healthy`, `degraded`, `unstable`,
99+
`unavailable`, `recovering`, and `operator_blocked`.
100+
101+
Runtime watchers own these transitions:
102+
- Success resets backend health to `healthy`
103+
- `SIGKILL` transitions to `unstable`, applies cooldown, and forbids immediate replay
104+
- Repeated backend failures transition toward `unavailable` and escalation
105+
- Exhausted recovery marks `operator_blocked` with an explicit reason
106+
107+
The loop should consume this state before reading raw logs. Raw logs are fallback
108+
evidence only when structured health records are missing or contradictory.
109+
110+
---
111+
112+
## Recovery ownership boundaries
113+
114+
The permanent target is:
115+
116+
```text
117+
watchers = healer + coordinator
118+
loop = oversight + escalation + audit
119+
```
120+
121+
Watcher telemetry must include enough structured evidence for the next watcher
122+
or policy layer to act without prompt-side inference.
123+
124+
Required telemetry by owner:
125+
- `improve`: `executor_exit_code`, `executor_signal`, `retry_strategy_used`,
126+
`retry_strategy_changed`, `remediation_attempt_number`,
127+
`remediation_lineage_id`, `prior_failure_signature`
128+
- `triage`: `blocked_reason`, `blocked_by_backend`, `retry_safe`,
129+
`queue_transition_recommendation`
130+
- `goal` / `propose`: `duplicate_reason`, `suppression_reason`,
131+
`starvation_detected`, `queue_deadlock_detected`
132+
- `watchdog` / runtime supervisor: `backend_health_transition`,
133+
`cooldown_applied`, `recovery_attempt_started`, `recovery_attempt_result`
134+
- `review`: remediation lineage and retry adaptation fields matching `improve`
135+
136+
When these fields exist, the loop should validate and audit them. It should not
137+
repeat the same log analysis unless the structured telemetry is absent,
138+
incomplete, or semantically changed.
139+
140+
---
141+
142+
## Recovery strategies
143+
144+
Autonomous recovery is bounded and auditable. The platform may attempt:
145+
- Executor restart: restart backend process, restart watcher, reinitialize runtime
146+
- Queue healing: `Blocked -> Backlog`, `Blocked -> Ready for AI`, stale lock cleanup
147+
- Runtime pressure mitigation: pause backend temporarily, reroute lightweight tasks,
148+
defer expensive remediation
149+
- Cooldown enforcement: wait until `safe_retry_after` before retrying the same lineage
150+
151+
Guardrails:
152+
- Do not widen runtime policy automatically
153+
- Do not increase `kodo` concurrency automatically
154+
- Do not bypass execution gates silently
155+
- Do not replay unsafe retries
156+
- Do not mutate queue state without structured evidence
157+
158+
---
159+
160+
## Self-healing state machine
161+
162+
The platform state machine is:
163+
164+
```text
165+
HEALTHY
166+
-> DEGRADED
167+
-> RECOVERING
168+
-> HEALTHY
169+
170+
RECOVERING
171+
-> UNSTABLE
172+
-> COOLDOWN
173+
-> RECOVERING
174+
175+
UNSTABLE
176+
-> OPERATOR_BLOCKED
177+
-> PARKED_OPERATOR_BLOCKED
178+
```
179+
180+
The loop orchestrates and audits transitions. Watchers own the local recovery
181+
attempts and must emit transition events.
182+
183+
---
184+
185+
## Queue healing rules
186+
187+
Queue healing is allowed only when structured metadata proves the transition is
188+
safe.
189+
190+
Automatic duplicate-deadlock breaker:
191+
192+
```text
193+
IF duplicate exists in Blocked
194+
AND no consumer can execute it
195+
AND retry_safe = true
196+
AND retry budget remains
197+
THEN transition Blocked -> Ready for AI
198+
```
199+
200+
Stale blocked recovery:
201+
202+
```text
203+
IF task is Blocked beyond stale threshold
204+
AND retry_safe = true
205+
AND replay budget remains
206+
THEN transition Blocked -> Backlog
207+
```
208+
209+
Required queue metadata:
210+
- `retry_lineage_id`
211+
- `retry_safe`
212+
- `backend_dependency`
213+
- `recovery_attempt_count`
214+
- `blocked_reason`
215+
- `duplicate_key`
216+
217+
Required invariants:
218+
- No infinite replay
219+
- No duplicate storms
220+
- No unsafe unblock
221+
- No queue freeze caused by duplicate suppression
222+
223+
---
224+
225+
## Recovery budgets
226+
227+
Investigation and recovery are bounded by machine-enforced budgets:
228+
229+
```yaml
230+
recovery_budget:
231+
max_cycles_before_escalation: 3
232+
max_equivalent_retries: 2
233+
max_recovery_attempts: 5
234+
```
235+
236+
After exhaustion, the system must escalate, park when appropriate, and wait for
237+
semantic evidence change. The loop should not perform fresh deep investigation
238+
for an unchanged, budget-exhausted root cause.
239+
240+
---
241+
242+
## Evidence fingerprinting
243+
244+
Use canonical evidence hashes to distinguish real change from timestamp churn.
245+
246+
Hash inputs:
247+
- exit code and signal
248+
- normalized stacktrace or failure category
249+
- queue state
250+
- watcher state
251+
- regression IDs
252+
- backend health state
253+
254+
Ignore:
255+
- timestamps
256+
- cycle IDs
257+
- run IDs
258+
- log ordering noise
259+
260+
Parked and stalled decisions should use these hashes. Timestamp-only changes are
261+
not new evidence.
262+
263+
---
264+
265+
## Formal parked behavior
266+
267+
`PARKED_OPERATOR_BLOCKED` is a persisted system state, not just loop prose.
268+
269+
Parked metadata must include:
270+
271+
```yaml
272+
parked_state:
273+
root_cause_signature: kodo_sigkill_plan_phase
274+
parked_reason: backend cooldown exhausted without safe retry
275+
unchanged_cycles: 14
276+
last_evidence_hash: abc123
277+
unpark_conditions:
278+
- backend_health_change
279+
- queue_change
280+
- runtime_config_change
281+
- watcher_state_change
282+
- execution_outcome_change
283+
```
284+
285+
When parked, skip repeated deep investigation. Check only unpark conditions and
286+
semantic evidence hash changes.
287+
288+
---
289+
290+
## Recovery telemetry
291+
292+
Recovery events should be emitted as structured records:
293+
- `recovery_attempt_started`
294+
- `recovery_attempt_result`
295+
- `cooldown_applied`
296+
- `backend_health_transition`
297+
- `queue_healing_decision`
298+
- `recovery_budget_exhausted`
299+
- `parked_state_entered`
300+
- `parked_state_unparked`
301+
302+
Loop summaries should report metrics derived from these records:
303+
- recovery success rate
304+
- retry adaptation rate
305+
- queue evolution quality
306+
- backend stability state
307+
- unchanged evidence cycles
308+
309+
---
310+
77311
## Prerequisites
78312

79313
Before starting the loop, confirm:
@@ -198,6 +432,10 @@ Collect exit codes and finding counts. Determine affected repos only from tool o
198432
199433
STEP 2 — TRIAGE:
200434
.venv/bin/operations-center-triage-scan --config config/operations_center.local.yaml --apply
435+
This scan now includes queue self-healing when tasks carry structured evidence labels:
436+
retry_safe, queue_deadlock/no_consumer, dedup:<key>, retry-lineage:<id>.
437+
It may transition Blocked→Ready-for-AI or Blocked→Backlog only when retry budgets
438+
and safety evidence allow it; otherwise it comments an escalation.
201439
202440
STEP 3 — BLOCKED/STALLED WORK INVESTIGATION:
203441
Read the last 3 cycle summaries from .console/log.md to identify repeated patterns.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 Velascat
3+
"""Runtime backend health model and registry."""
4+
5+
from .models import (
6+
BackendFailure,
7+
BackendHealthRecord,
8+
BackendHealthState,
9+
RecoveryStrategy,
10+
)
11+
from .registry import BackendHealthRegistry, HealthTransition
12+
13+
__all__ = [
14+
"BackendFailure",
15+
"BackendHealthRecord",
16+
"BackendHealthRegistry",
17+
"BackendHealthState",
18+
"HealthTransition",
19+
"RecoveryStrategy",
20+
]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 Velascat
3+
"""First-class runtime backend health state.
4+
5+
The registry is intentionally small and deterministic. It records facts the
6+
watchdog loop used to infer repeatedly from logs: failure counters, cooldowns,
7+
last success/failure signatures, and the bounded strategy currently allowed.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass, field, replace
13+
from datetime import UTC, datetime
14+
from enum import Enum
15+
16+
17+
class BackendHealthState(str, Enum):
18+
UNKNOWN = "unknown"
19+
HEALTHY = "healthy"
20+
DEGRADED = "degraded"
21+
UNSTABLE = "unstable"
22+
UNAVAILABLE = "unavailable"
23+
RECOVERING = "recovering"
24+
OPERATOR_BLOCKED = "operator_blocked"
25+
26+
27+
class RecoveryStrategy(str, Enum):
28+
NONE = "none"
29+
RETRY_AFTER_COOLDOWN = "retry_after_cooldown"
30+
RESTART_BACKEND = "restart_backend"
31+
RESTART_WATCHER = "restart_watcher"
32+
REINITIALIZE_RUNTIME = "reinitialize_runtime"
33+
REDUCE_PRESSURE = "reduce_pressure"
34+
ESCALATE = "escalate"
35+
36+
37+
@dataclass(frozen=True)
38+
class BackendFailure:
39+
signature: str
40+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
41+
exit_code: int | None = None
42+
signal: str | None = None
43+
reason: str | None = None
44+
45+
46+
@dataclass(frozen=True)
47+
class BackendHealthRecord:
48+
backend_id: str
49+
state: BackendHealthState = BackendHealthState.UNKNOWN
50+
failure_count: int = 0
51+
recovery_attempt_count: int = 0
52+
last_success_at: datetime | None = None
53+
last_failure: BackendFailure | None = None
54+
cooldown_until: datetime | None = None
55+
safe_retry_after: datetime | None = None
56+
recovery_strategy: RecoveryStrategy = RecoveryStrategy.NONE
57+
operator_blocked_reason: str | None = None
58+
59+
def with_update(self, **changes: object) -> "BackendHealthRecord":
60+
return replace(self, **changes)

0 commit comments

Comments
 (0)