Skip to content

fix(watchdog): rate-based C7 error-storm detection — replace flat-count heuristic#142

Open
ynaamane wants to merge 13 commits into
Ruya-AI:mainfrom
ynaamane:fix/watchdog-rate-storm
Open

fix(watchdog): rate-based C7 error-storm detection — replace flat-count heuristic#142
ynaamane wants to merge 13 commits into
Ruya-AI:mainfrom
ynaamane:fix/watchdog-rate-storm

Conversation

@ynaamane

Copy link
Copy Markdown
Contributor

Problem

guard-watchdog's C7 "erroring/inert-guard" detector classified a respawn/error storm from a flat 256 KB append-mode log tail, with no recency or rate awareness. As noted in #140, every flat-count rule had a residual in one direction or the other because the tail mixes generations:

  • False-negative: a current error storm masked when stale productive-prune lines from an earlier (dead, healthy) generation still in the window outnumber the errors.
  • False-positive: a long-lived healthy daemon that self-recovered ≥ loop_trip scattered transient errors (streak reset each time, never escalated), with its productive prunes scrolled out of the window, flagged as inert.

Fix

scan_log_text (watchdog.py) now keys the verdict on restart rate within a time window, using the ISO timestamps on --- Guard daemon started at <ISO> --- lines (PATH A), and keeps the original flat-count as a fallback for timestamp-less old logs (PATH B):

  • Rate storm — the maximum daemon restarts in any RATE_WINDOW_S = 3600-second sliding windowRATE_STORM_TRIP = 5. A sliding window (rather than anchoring on max(timestamps)) is robust to a forged/implausible future-or-past timestamp: a lone outlier forms its own window of size 1 and cannot mask a genuine restart cluster. This is the literal "many restarts close together in time" definition from the issue.
  • Escalation-aware inert — when it isn't a storm, a single generation that escalated (cycle_escalations ≥ 1) with ≥ loop_trip per-cycle errors and errors > productive_prunes is flagged as genuinely stuck. A recovered-healthy daemon (never escalated) is not flagged — that's the FP fix, using the "never escalated" discriminator from the issue.
  • _DAEMON_START_RE is line-anchored (same defence class as _PRUNED_RE) so a mid-line forged --- Guard daemon started at <ISO> --- substring (e.g. inside a team-name log line) can't register as a restart.
  • Tz-aware timestamps are treated as unparseable (the guard emits naive local time; a tz-aware result is forged/unexpected) — this avoids a naive-vs-aware comparison TypeError.
  • The storm reason string is cause-neutral ("respawn storm (rate-based)") and reports per-cycle errors, futile prune cycles, and escalations, so a futile-prune storm on a modern log isn't mislabelled as an "error" storm.
  • The futile-prune branch is byte-identical to before (verified).

Tests

tests/test_guard_watchdog.py — 14 new tests covering: storm-flagged-despite-stale-prunes (FN), scattered-recovery-not-flagged (FP), spread-out-restarts-not-flagged, the real f641174c fixture preserved, no-timestamp flat-count fallback, escalated-inert-flagged, forged-future-timestamp-still-flags-storm, midline-forged-header-excluded, tz-aware-treated-as-unparseable, inclusive-window-boundary, cause-neutral-reason, and a characterization test pinning the PATH A/PATH B asymmetry.

Full suite: 1601 passed, 0 related failures (--ignore=tests/test_model_detection.py). One unrelated pre-existing concurrency test (test_guard_hardening.py::TestG4_PidfileWriteIsAtomic::test_concurrent_starts_only_one_spawn_wins) is load-flaky under full-suite CPU contention — passes 3/3 in isolation and at this PR's base; the diff does not touch spawn/pidfile code.

Scope / known limitations (documented, not silently dropped)

  • Deferred residual: PATH A flags a single-generation inert daemon only if it escalated. A non-escalated inert daemon on a modern or mixed-generation log is not flagged (the moment ≥1 timestamp parses, the flat-count PATH B is suppressed). Reachable via non-consecutive errors (no C2 escalation), an external kill before escalation, the agents-active escalation deferral (GUARD_CYCLE_ERROR_DEFER_MAX), or a mixed-generation log. This is pinned by a characterization test. A recency-anchored / escalation-independent inert signal would close it — happy to do a focused follow-up if you'd like single-generation inert parity on modern logs.
  • The sliding window detects storms anywhere in the visible 256 KB tail; a recently-resolved storm can still flag until its start-lines scroll out of the window (the detector is a pure function with no wall-clock "now"). The bounded tail is the recency mechanism.

Base: main @ v1.8.33.

Closes #140.

ynaamane added 13 commits June 17, 2026 23:02
… daemon_start_times/recent_starts to LoopReport (PLAN P0-A)
…ount fallback (PLAN P0-C)

Rate path (PATH A): when >=1 parseable ISO daemon-start timestamp exists, count
restarts within RATE_WINDOW_S=3600s of the most recent one. If recent_starts
>= RATE_STORM_TRIP=5, flag as error respawn storm regardless of stale productive-
prune lines in the window (fixes FN). If recent_starts < RATE_STORM_TRIP, rate
path is authoritative — flat-count suppressed (fixes FP: scattered recovered errors
on a single healthy daemon no longer false-flag).

Flat-count fallback (PATH B): unchanged R15 discriminator, fires only when 0
parseable timestamps exist (old daemon versions, malformed lines). Zero regression
on pre-ISO-header logs.

The futile-prune branch (watchdog.py:187+) is byte-identical — untouched.
…..T-6 (PLAN P0-D)

Import RATE_WINDOW_S and RATE_STORM_TRIP from watchdog. Clean up T-2 docstring.

AC-1 (T-1): storm of 5 rapid respawns flagged despite stale prune lines masking
             errors — proven RED at base (FN), GREEN after rate-window fix.
AC-2a (T-2): single healthy daemon with 21 scattered recovered errors NOT flagged —
             proven RED at base (FP), GREEN after rate-window fix.
AC-2b (T-3): 6 restarts 4h apart NOT flagged — GREEN at base and after (preserved).
AC-3  (T-4): real f641174c fixture NOT flagged — GREEN at base and after (preserved).
Edge  (T-5): no-ISO-timestamp log falls back to flat-count — GREEN at both.
Edge  (T-6): mixed parseable/None timestamps; anchor uses latest parseable — proven
             RED at base (FN), GREEN after fix.
watchdog.py: collapse redundant else-branch in daemon-start parsing loop
into a single try/except with conditional expression (R-3/S-2).

test_guard_watchdog.py: move _error_cycle/_escalation helpers to module-level
alongside _futile_cycle/_good_cycle/_daemon_start (R-1); add datetime/timedelta
as module-level imports instead of repeating inside 4 test methods (R-2/S-3);
fix T-5 docstring stale '< 2 parseable timestamps' line (A-4/S-4).
…C-2a future-anchor, C-2b midline-regex, M-1 tz, M-3 boundary)
…gate (PLAN P0-C round-2)

C-2a: replace max()-anchored window with a sliding-window algorithm.
Old code anchored to max(parseable): a single forged far-future timestamp
inflated the anchor, collapsing recent_starts to 1 for genuine storm clusters.
New algorithm: sort timestamps, for each t1 count t2 in [t1, t1+RATE_WINDOW_S]
(0 <= delta <= RATE_WINDOW_S), take the max across all t1. A lone outlier
forms its own window of size 1 and cannot suppress a genuine cluster.
O(n^2) in daemon_starts (n < 50 in practice).

C-2b: fix _DAEMON_START_RE anchor for the real "--- Guard daemon started" format.
Previous ^\s* missed the leading dashes. Pattern is now ^(?:---\s*|\s*) to
cover both the dashes form and any whitespace-only prefix variant.
Mid-line injections ("Team 'Guard daemon started at T' state preserved") still
cannot match because text before them is neither dashes nor whitespace.

M-1: tz-aware fromisoformat guard — parsed datetimes with tzinfo set are
treated as None to avoid TypeError when mixing tz-aware and naive in (t2-t1).

C-1: escalation-aware inert gate. When PATH A's recent_starts < RATE_STORM_TRIP
(not a storm), check for a single genuinely stuck daemon: cycle_escalations >= 1
AND cycle_errors >= loop_trip AND cycle_errors > productive_prunes. The C2
escalation line proves the daemon exited for respawn (not a recovered transient).
Adds "inert/erroring guard (escalated)" reason.
Deferred residual: a daemon that errors without ever escalating is not caught
here; tracked in TODO.md for follow-up.

All 32 watchdog tests pass.
Altitude fix (L-2): reason string "in the last Nmin" was misleading for a
sliding-window algorithm — the window anchors to the densest cluster in the log,
not to the present moment. Changed to "within a Nmin window".

Reuse: nothing to reuse (novel algorithm).
Simplification: nothing redundant (parseable is a single variable reused correctly).
Efficiency: cycle_errors/cycle_escalations findall always runs; required because
both fields are public LoopReport fields consumed by callers regardless of path.
The field comment said 'starts within RATE_WINDOW_S of the anchor timestamp'
which described the old max()-anchored design (round-2 fixed with a sliding window).
Updated to 'max restarts in any RATE_WINDOW_S-second sliding window' which
accurately reflects the current algorithm.
@junaidtitan junaidtitan added bug Something isn't working watchdog review-ready labels Jun 17, 2026
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working review-ready watchdog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guard-watchdog: rate-based error-storm detection (replace flat-count heuristic)

2 participants