Away-mode watchdog: notice a dead supervisor, and retract its alarm record on a fresh entry #27

Merged
john merged 12 commits from fm/fix-22-watchdog-liveness-v2 into main 2026-09-05 05:21:03 +00:00
Owner

Supersedes #22, which cannot be updated without a force-push.

Why a new PR rather than updating #22

The validation pipeline's own rebase step — an automatic part of every run — rebased this work onto a main that had since gained #20 and #23. PR 22's branch is an ancestor of the original commit but not of the rebased head, so updating #22 in place would require a force-push.

Firstmate verified that every file PR 22 changes exists in this head, so nothing is lost. The alternatives were rejected deliberately: force-pushing rewrites a branch nobody authorised rewriting, and cherry-picking onto #22's branch would land content the pipeline never validated.

What this delivers

The away-mode watchdog notices a dead supervisor, serialises its stale-lock takeover, and fails closed on identity — plus the fix the captain decided tonight: a fresh /afk entry now retracts the durable check afk-watchdog wake record, not merely its rate-limit marker.

Without that retraction, a supervisor crash during an away session could resurface an "away mode is still broken" alert after the incident was resolved, costing a turn on a stale alarm. The code immediately beside it already did exactly this for the sibling wedge alarm; this brings the two into line.

The test was extended to assert the record is retracted, not only that the marker cleared — the previous test passed while the record survived, which is why this shipped.

Validation

Full pipeline passed. Note for the record: two earlier runs on this work died with an opaque parse error that was not a content fault — it was caused by a change to Pi's default model, and swapping the model resolved it.

Supersedes [#22](https://git.pyr0.dev/john/firstmate/pulls/22), which cannot be updated without a force-push. ## Why a new PR rather than updating #22 The validation pipeline's own rebase step — an automatic part of every run — rebased this work onto a main that had since gained #20 and #23. PR 22's branch is an ancestor of the *original* commit but not of the rebased head, so updating #22 in place would require a force-push. Firstmate verified that **every file PR 22 changes exists in this head**, so nothing is lost. The alternatives were rejected deliberately: force-pushing rewrites a branch nobody authorised rewriting, and cherry-picking onto #22's branch would land content the pipeline never validated. ## What this delivers The away-mode watchdog notices a dead supervisor, serialises its stale-lock takeover, and fails closed on identity — plus the fix the captain decided tonight: **a fresh `/afk` entry now retracts the durable `check afk-watchdog` wake record**, not merely its rate-limit marker. Without that retraction, a supervisor crash during an away session could resurface an "away mode is still broken" alert *after* the incident was resolved, costing a turn on a stale alarm. The code immediately beside it already did exactly this for the sibling wedge alarm; this brings the two into line. The test was extended to assert the **record** is retracted, not only that the marker cleared — the previous test passed while the record survived, which is why this shipped. ## Validation Full pipeline passed. Note for the record: two earlier runs on this work died with an opaque parse error that was **not** a content fault — it was caused by a change to Pi's default model, and swapping the model resolved it.
john added 11 commits 2026-09-04 06:28:45 +00:00
state/afk-watchdog.sh was a live, untracked, hand-written file: invisible to
any crewmate worktree, untested, and one rm from gone for a component whose
entire job is detecting away-mode failure.

Promotes it to bin/fm-afk-watchdog.sh and fixes two confirmed faults:

(a) alarm() ran bin/fm-wake-drain.sh with output discarded before appending
    its own record. That drains and deletes the durable wake queue on every
    successful call, destroying every other pending record (sixteen hourly
    alarms on 2026-08-27 left exactly one surviving record). The call served
    no purpose and is simply removed; alarm() now only ever appends its own
    check:afk-watchdog record.

(b) The alarm's only delivery path was a durable record read at the next
    session start, so the one component that detects an away-mode outage
    could not interrupt the outage it was reporting. It now also fires the
    active-alert channels through the same config/wedge-alarm mechanism the
    away-mode injection wedge alarm uses, extracted out of
    bin/fm-supervise-daemon.sh into bin/fm-wedge-alarm-lib.sh so both alarm
    sources share exactly one delivery implementation instead of a second,
    parallel one.

bin/fm-wake-drain.sh's own contract is untouched; no caller changes.
Colocated tests in tests/fm-afk-watchdog.test.sh include a regression test
that reproduces the sixteen-alarms-one-record queue loss against the old
behavior and passes against the fix.
The portable-serial-1 CI job failed at head c2f959ce after 16m49s, while
every local reproduction in the real CI container (15 runs, including
simulated shared-runner contention) passed cleanly in 7-8 minutes. That
stall-then-fail duration matches a dead nameserver in the runner host's
/etc/resolv.conf causing container DNS to time out mid-clone, which was
not yet fixed when this job ran. No code change; empty commit to re-run CI.
Closes four review findings on PR 22 (fm-afk-watchdog-untracked-and-eats-queue):

- HIGH: a refresh of an already-running away-mode session never checked
  whether the watchdog itself was still alive, so a crashed watchdog was
  never noticed or restarted while the daemon kept running. The refresh
  path now verifies the watchdog's own lock independently and restarts it
  only when it is not held by a live watchdog, preserving the no-stacking
  behavior when one is alive.
- HIGH: judging a recycled-pid watchdog lock stale and then removing and
  reclaiming it were not serialized, so two concurrent claimants could both
  validate the same stale owner and each remove-and-reclaim in turn,
  leaving one running lockless. That judge-then-act sequence is now
  serialized behind a short-lived takeover mutex.
- MEDIUM: the daemon published its pid record with a truncating redirect,
  so a watchdog reading mid-write could see an empty, fresh-mtime file and
  alarm a false outage, whose rate-limit marker then suppressed a real
  outage for the following hour. The pid record is now published
  atomically via write-then-rename.
- MEDIUM: when a daemon lock recorded no pid-identity, liveness degraded to
  a ps command-line substring match, which a reused pid from another
  firstmate home (or any lookalike command line) could satisfy. That
  fallback now fails closed instead of matching by command line.

Each finding has a dedicated regression test, demonstrated failing against
the pre-fix code (the HIGH-2 and MEDIUM-3 races needed a test-only delay
seam to make them deterministic rather than depending on rare scheduling).
no-mistakes(document): Document watchdog refresh liveness and stale-artifact retraction
Some checks failed
CI / Test coverage guard (pull_request) Successful in 13s
CI / Behavior portable parallel 2 (pull_request) Successful in 3m3s
CI / Behavior portable parallel 1 (pull_request) Successful in 3m10s
CI / Lint shell scripts (pull_request) Successful in 4m46s
CI / Behavior portable serial 1 (pull_request) Failing after 14m22s
CI / Behavior portable serial 4 (pull_request) Successful in 9m49s
CI / Repo invariants (pull_request) Successful in 5s
CI / Behavior portable serial 2 (pull_request) Successful in 12m19s
CI / Behavior portable serial 3 (pull_request) Successful in 17m0s
CI / Behavior tests (Herdr) (pull_request) Successful in 7m13s
809aa549cb
test(afk): wait for the watchdog's durable record instead of sampling it once
All checks were successful
CI / Test coverage guard (pull_request) Successful in 13s
CI / Behavior portable parallel 2 (pull_request) Successful in 3m0s
CI / Behavior portable parallel 1 (pull_request) Successful in 3m8s
CI / Lint shell scripts (pull_request) Successful in 5m2s
CI / Behavior portable serial 1 (pull_request) Successful in 14m29s
CI / Behavior portable serial 4 (pull_request) Successful in 9m46s
CI / Repo invariants (pull_request) Successful in 4s
CI / Behavior portable serial 2 (pull_request) Successful in 12m17s
CI / Behavior portable serial 3 (pull_request) Successful in 16m56s
CI / Behavior tests (Herdr) (pull_request) Successful in 7m49s
db05385004
CI / Behavior portable serial 1 failed on this branch with:

  not ok - the fresh watchdog process alarmed without appending its durable record:

test_a_freshly_started_watchdog_does_not_reset_the_missing_heartbeat_grace
spawns a REAL watchdog process and then waits for the alarm rate-limit
marker before asserting that the durable check: afk-watchdog wake record
was queued. Those two artifacts are not written together: alarm() writes
the marker first, then appends the record through a subshell that sources
the wake library and takes the queue lock. Measured inside .github/ci's
own image, the record lands 35ms after the marker on an idle container.
wait_for polls at 100ms, so the case sampled the queue at an instant that
can fall inside that gap - and did, on a runner carrying ten concurrent
jobs.

Wait for the row the same way the marker is waited for, through a
wait_for_row helper alongside wait_for and row_for_key. The assertion is
unchanged in strength: the case still fails if the record never appears,
it just stops reading a second process mid-write.

Reproduced against the pre-fix test in the CI image pinned to one CPU
(1 failure in 12 runs, the exact CI line above); 12/12 pass with the fix,
and the full portable-serial-1of4 lane is green in that image.
john merged commit 11949fbec4 into main 2026-09-05 05:21:03 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
john/firstmate!27
No description provided.