HOMELAB-1741: feat(pi-swarm-suite): challenge-before-reap watchdog #46
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1741-watchdog-challenge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Redesigns the LD0.5 stall watchdog (HOMELAB-1668, v1) from silence-only reap to challenge-before-reap, per the operator's ruling:
v1 reaped on silence alone (
status: running+ run in flight + no activity-bus event forthresholdMinutes), which misfires on a healthy agent parked in a long, silent blocking wait (aget_subagent_result(wait)past the threshold, or a single long silent tool call). Silence is a suspicion, not a verdict.graceMinutes: 0restores the v1 single-phase behaviour verbatim.thresholdMinutes: 0/enabled: falseunchanged. Idle-but-live children (HOMELAB-1577:running+ no run in flight) are still never challenged or reaped.Challenge mechanism — chosen: a pi steer
challenge: (id) => manager.steer(id, STALL_WATCHDOG_CHALLENGE_TEXT).Why, over the alternatives the ticket listed:
session.steer()); only a live run can drain it and emit activity. HOMELAB-1632 proved the exact differentiator — a steer queued behind a stalled provider request is never processed. A synthetic activity-bus ping would only re-arm the watchdog's own clock and prove nothing about liveness (rejected). A "marker the next event clears" is just the grace window; it needs the same real event source, which the steer provides.steer()is the existing public verb behindswarm-attach steer. For a record whose status does not change it publishes no activity event (emitAnyChangeonly publishesstatuson an actual status change), so the challenge cannot accidentally answer itself on the real wiring. A dedicatedsendCustomMessagechannel would have required a new AgentManager method; not needed./, so pi's skill/template expansion is a no-op.The BLOCKER-1 class, handled (regression test first)
The challenge's own synchronous bus events must not count as the answer — the same class as the HOMELAB-1668 BLOCKER-1 (abort's
statusevent cleared the reap marker). The grace marker is set before the injection, and achallengingset makesobserve()ignore every event the manager publishes synchronously from insidechallenge().The guard is load-bearing, and the ticket's test-first requirement is met:
tests/core/stall-watchdog-challenge.test.tsfail on the v1 code (12/12 failed, recorded).statusevent cancels grace → the agent is re-challenged instead of reaped,expected [] to deeply equal ["a1"]).Notifications name the phase
STALL SUSPECTED — liveness challenge sent (phase 1 of 2)+Challenged: via steer at <ISO> — grace until <ISO> (10m).STALL CONFIRMED — challenged, no answer by grace end — agent reaped automatically (phase 2 of 2). A later reap can no longer read as the first silent detection. Thestall-escalatednotice carries the challenge context too.Config + surface
stallWatchdog: { enabled, thresholdMinutes, graceMinutes }— same per-field global/project merge, sanitize, decision-time read, and clamping./agents → Settingsgains Stall grace (min) (0 = legacy single-phase). README,docs/architecture.md,docs/attach-protocol.md,src/suite-settings.tsupdated.VERIFY — E2E re-derived (real
pi --mode rpc, scratchPI_CODING_AGENT_DIR, worktree--extension,thresholdMinutes: 1,graceMinutes: 1)Harness
/tmp/watchdog-verify-1741/(scratch; blackhole SSE server + scripted root that spawns a backgroundAgent; nothing touched by hand after the spawn).child-keepalive)stopped/wedged=trueat t+188sbash sleep 900(child-bash900)stopped/wedged=trueat t+188slastActivity: tool_activity(start, bash)), reaped at grace endbash sleep 130(silent past threshold, resolves within grace)completed/wedged=falseat t+138sRead back from the scratch session file, Wedge A:
"A wedged agent cannot respond to the challenge" is proven directly: in both wedge scenarios the challenge was delivered (
delivered: truethrough the realmanager.steer()), and the agent still produced zero activity and was reaped at grace end. If the challenge had counted as activity, the grace would have been cancelled and the reap would never have fired.Repo gates
npm run typecheckclean.npx vitest run: 1300 tests / 99 files passed (baseorigin/main= 1287 / 98 → +13 tests, +1 file).~/.repos/pi-extensionsuntouched:git status --porcelain→ only pre-existing untracked.rpiv/and.worktrees/; all changes are on this branch. Staged explicit paths only (node_modulessymlink never staged).Honest gaps / not verified
AgentManagertests (isStreaming: falseon a real record → no challenge, no reap), not end-to-end: the harness's background child terminalizes on completion, and reproducing the HOMELAB-1577 wake shape E2E needs a post-settle steer.graceMinutesadds up tothreshold + graceof extra latency before an actually-wedged agent is reaped. That is the intended trade the operator asked for;graceMinutes: 0opts out.Not merged — Class C; a landing worker owns review + merge + rollout.
Closes HOMELAB-1741.
v1 (HOMELAB-1668) reaped on silence alone: running + run in flight + no activity-bus event for thresholdMinutes. That misfires on a healthy agent parked in a long, silent blocking wait (a `get_subagent_result(wait)` past the threshold, or a single long silent tool call) — it looks exactly like a wedge. Silence is a suspicion, not a verdict. Two-phase detection, per the operator's ruling ("ping the agent; if it doesn't answer in 10 min post-check, then kill it"): phase 1 — a silence breach CHALLENGES the agent with a liveness steer (manager.steer, the same verb `swarm-attach steer` uses; a live run drains it and emits activity, a frozen run's event loop never does) and opens a grace window (new `stallWatchdog.graceMinutes`, default 10); phase 2 — ANY activity-bus event from the agent during grace cancels the reap; still silent at grace end → the unchanged v1 reap path (abort + post-abort verification + escalation + durable entry + loud notice). The challenge's OWN synchronous bus events are not an answer (the HOMELAB-1668 BLOCKER-1 class, where abort's status event cleared the reap marker): while the watchdog is inside `challenge()` every event the manager publishes is attributed to the challenge. The grace marker is set BEFORE the injection, so the guard is load-bearing — the new regression test fails when it is removed. - `graceMinutes: 0` = the v1 single-phase behaviour, verbatim. - `thresholdMinutes: 0` / `enabled: false` unchanged. - Idle-but-live children are still never challenged or reaped. - Notices name the phase: phase 1 says "challenged at T, grace until T+10m", phase 2 says "challenged, no answer by grace end" — a reap can no longer read as a first-phase silent detection. - No AgentManager semantics change: `steer()` is the existing public verb, and it publishes no activity event for a record whose status is unchanged. - `/agents → Settings` gains "Stall grace (min)"; README + architecture + attach-protocol + settings docs updated. Tests: 12 new regression tests (tests/core/stall-watchdog-challenge.test.ts) that fail on the v1 code and on the naive (unguarded) v2 wiring; existing watchdog/settings/manager suites adapted to the two-phase default. Full suite: 1300 tests / 99 files, typecheck clean. E2E (real pi RPC, scratch agent dir, 1m/1m thresholds): blackholed-provider keep-alive wedge and `bash sleep 900` wedge each challenged (~1m56s) then reaped at grace end (stopped, wedged=true); a `bash sleep 130` call that resolves within grace was challenged and survived (completed, wedged=false).Independent landing review — HOMELAB-1741 (head
ba257e5)Two reviewers, each in a fresh
pi -p --no-sessionprocess, briefed only on the PR diff + the ticket's verbatimacceptance criteria + the frozen constraints (never on the implementer's summary). Different focus, both against
primary sources; the reviewer wrote no file in this repo.
Independently re-derived by the reviewer (not taken from the PR body)
git archive origin/mainscratch, new test file copied in →Tests 12 failed (12).(Landing worker re-ran this independently: same result, 12/12.) Nuance: 2 of the 12 (#5 idle-but-live, #7
graceMinutes: 0) also hold on v1 and fail only becauseStallTickResult.challengeddoes not exist there —the behavioural discriminators are the other ten.
challengingguard is load-bearing and pinned. Deletingif (this.challenging.has(id)) return;failsexactly one test, the dedicated "a wedged agent cannot answer: the challenge's OWN bus event is not an answer".
(Landing worker reproduced: 1 failed | 11 passed.)
1300 / 99 files; base1287 / 98;+13 tests / +1 file(12 in the new file + 1 newmanager-level test);
tsc --noEmitexit 0.graceMinutes: 0is v1-verbatim. A/B random-walk equivalence run against v1 source and v2 source:6,656 identical output lines (reap order, notices, rendered text,
checked,wedgedAt, abort calls).abort()without a pendingchallenge ≥ grace old, never inside the threshold window, never for a non-running or no-run-in-flight record.
status-is-an-answer (E), legacy path (F),idle-but-live exemption (G), run-start reset (I) all killed by the suite.
manager.steer()did not cancel its owngrace window; the
stall-reapedentry carries thechallengeobject exactly 60,000 ms afterstall-challenged.Concerns (mergeable — posted per the landing contract, not blockers)
stall-watchdog.ts:646-652— the phase-2 notice rebuildsgraceUntil/graceMinutesfrom the currentconfig instead of the values announced in phase 1, so if
graceMinuteschanges mid-grace the two noticescontradict each other in the operator's transcript (
StallChallengeInfo's own doc says it is carried forward).Minimal fix: store
graceUntil/graceMinuteson theChallengeRecordand carry those.stall-watchdog.ts:638-643— raisingthresholdMinutesmid-grace leaves the pending challenge in place, so alater silence breach reaps on the old ping (notice still reads "challenged, no answer by grace end"). Benign
in direction (the agent got more than its promised grace, never less), but the ping is not fresh. Minimal fix:
if (silenceMs <= thresholdMs) { this.challenged.delete(record.id); continue; }.graceUntil/graceMinutes/viaare not asserted anywhere (zeroinggraceUntilpasses all 1300 tests), and the wholedelivered: false/challenge()-throws path is unpinned(forcing
delivered = truepasses 1300/1300). Two small tests close both; the behaviour itself was probed andis correct.
bash sleep 130"false positive survives" run does show the child challengedat silence 1m56s and never reaped, but the child completed ≈44 s before the grace deadline, so the reap was
never due — that run cannot discriminate the cancellation. The "activity cancels the reap" property is carried
by the unit + real-
AgentManagertests (mutants C/D/E killed), not by that E2E row; anddriver.mjs'ssurvivesverdict would also pass if nothing had happened at all. Recorded here so the evidence is notover-claimed.
get_subagent_result(wait)was exercised only by thebash sleepsubstitute. Underdefaults it is safe (
toolWaitCapSec120 s returns and re-emits activity well inside the 10 m threshold), buttoolWaitCapSec×thresholdMinutes + graceMinutesis now a coupled pair (cap ceiling 3600 s vs 20 m defaultbudget) that no doc names. Worth one line in the README/ticket.
graceMinutes: 0): onceverifyReapsescalates a still-streaming reaped run it doesreaped.delete(id), so the only path that clearsescalated(if (this.reaped.delete(id))) can never fire again — a resumed record then escapes the watchdogfor the rest of its life. Out of scope here; follow-up filed.
NITs (README "ANY activity" precision re
driver;delivered: truealso covers a queued steer with no session;SUITE(HOMELAB-1668/1741)vs the comma form; stale satellite JSDoc insettings.ts/index.tscalling it a"pair") are not repeated here in full.
Landing worker verdict
No blocker. CI green on the head sha. Proceeding to merge, then GitOps continuation (ff-only update of the
live
~/.pi/agent/extensionscheckout) per the repo's rollout contract.Landed — HOMELAB-1741 (merge commit
4b48981)pi -p --no-session, briefed only on diff + ticket ACs + frozen constraints): merge with concerns — no blocker in bothba257e5merge→4b48981f3ff20a432ecf0ffb790a8a2c25e6f2e8(parentseba861b+ba257e5); branch kept~/.pi/agent/extensionsfast-forwardedeba861b → 4b48981;git rev-list --count HEAD..forgejo/main= 0Rollout evidence
~/.pi/agent/extensionsHEAD =4b48981; the livepi-swarm-suite/src/core/stall-watchdog.tscontains thechallenge logic;
STALL_WATCHDOG_DEFAULT_GRACE_MINUTES = 10andstallWatchdogDefaults.graceMinutes = 10are live.and after the ff (md5 diff clean), and the 3 untracked entries are unchanged.
they loaded. Defaults:
thresholdMinutes 10,graceMinutes 10;graceMinutes: 0reverts to v1 single-phase.Residual / follow-up
HOMELAB-1743(Backlog) captures the review findings that were out of scope here: the pre-existing stickyescalatedmarker, notice-vs-config fidelity on a mid-grace config change, the stale-challenge window, theuntested
delivered: false/ phase-1 notice fields, the discriminating false-positive E2E scenario, and thetoolWaitCapSec×threshold + gracecoupling to name in the README.