Promote the away-mode watchdog into tracked bin/, and stop it failing silently in five different ways #22

Closed
john wants to merge 8 commits from fm/fm-afk-watchdog-untracked-and-eats-queue into main
Owner

Why this was a defect and not a chore

state/afk-watchdog.sh was a live, hand-written, untracked file in the gitignored private state directory — invisible to any worker's worktree, untested, unreviewed, present in no other firstmate home, and one rm from gone. For the component whose entire job is detecting away-mode failure, that fragility was the defect.

The two faults it was promoted to fix

It destroyed the durable wake queue it exists to protect. alarm() ran the wake drain with output discarded before appending its own record; the drain moves the queue aside, prints it, then removes it, restoring only on non-zero exit. So every alarm silently destroyed every pending wake. Evidence: sixteen hourly alarms on 2026-08-27 left exactly one surviving record — each had eaten the previous.

Its only delivery path was a record read at the next session start. The one component that detects an away-mode outage could not interrupt the component having it. It shouted sixteen times into a log file. It now routes through the existing wedge-alarm channels rather than a second parallel mechanism.

Five review rounds, all of them finding real silence

Every round found another way this thing could fail to report — which is worth stating plainly, because a monitor that fails quietly is worse than no monitor:

  • Daemon liveness was machine-wide. A sibling firstmate home with a healthy daemon would mask a dead local one, so the watchdog would never alarm — the exact failure it exists to catch, rebuilt inside itself. Now scoped to this home's own record.
  • Nothing launched it. The promoted script had no caller, so the untracked hand-started copy would have stayed what actually ran. A promotion nothing launches is not a promotion.
  • The launcher wiring was race-able three ways — it could start before the daemon wrote its pid file and false-alarm immediately; its check-then-launch was TOCTOU-raceable into a double launch; and it would have started a second watchdog beside a still-running legacy one, violating the constraint it was written to honour. Now claims its singleton atomically via its own lock, mirroring how the supervisor daemon already claims its own, and only ever attempts a launch on the fresh-start path.
  • The heartbeat check was skipped entirely when the beat file was absent — daemon alive but no beat file ever written meant no check ran at all and the cycle reported healthy forever. Grace is now anchored on a fact about the daemon rather than on when the watchdog happened to look, so a late-starting watchdog cannot hand a fresh grace period to a long-dead watcher.
  • The alarm rate-limit marker survived an away-mode session boundary. Alarm at 10:00, captain returns, re-enters at 10:20, daemon fails again — the 10:25 check saw a 25-minute-old marker and returned silently, with no alarm, no alert and not even a log line, for a brand-new failure in a brand-new session. It is now cleared as part of the same fresh-entry cleanup that already handles the escalation buffer and wedge marker; a mid-session refresh deliberately does not clear it, or the alarm storm the limit exists to prevent comes back.

One known limitation, stated rather than engineered around

A rapid exit-and-re-enter inside the legacy watchdog's own poll window can briefly run both copies. It is one-time and transitional — only possible while a pre-this-PR hand-started watchdog is alive — self-limiting, and cannot recur once every watchdog on a machine is lock-based. Fixing it would need either signalling the legacy process (forbidden) or a heuristic guess at whether one is running, and a guess like that outlives the problem and then misfires. Documented instead.

Deliberately not fixed here

A recycled pid can mask a dead lock owner, which would permanently disable whatever the lock guards. The general fix belongs at the shared lock helper — used by the daemon lock, the wake-queue lock and the watcher lock — and changing that primitive for one task's benefit is far too much blast radius. This PR uses a local identity check mirroring the pattern the supervisor daemon already proves. The general problem is filed separately and looks like the same root cause as upstream issue 3279.

Tests

The central one reproduces the sixteen-alarms-one-record loss and proves a pending wake now survives an alarm. Plus: a live foreign-home daemon with a dead local one must alarm; two rapid entries must produce exactly one watchdog; a refresh against an active legacy session must launch none; a new session's failure inside the rate-limit window must alarm while a mid-session refresh must not reset it. All fail against the pre-fix code.

Pipeline passed clean — review, test, document, lint — and pushed at c2f959ce. pr and ci skipped because the tool has no Forgejo provider; this PR is what fires CI.

## Why this was a defect and not a chore `state/afk-watchdog.sh` was a live, hand-written, **untracked** file in the gitignored private state directory — invisible to any worker's worktree, untested, unreviewed, present in no other firstmate home, and one `rm` from gone. For the component whose entire job is detecting away-mode failure, that fragility *was* the defect. ## The two faults it was promoted to fix **It destroyed the durable wake queue it exists to protect.** `alarm()` ran the wake drain with output discarded before appending its own record; the drain moves the queue aside, prints it, then removes it, restoring only on non-zero exit. So every alarm silently destroyed every pending wake. Evidence: sixteen hourly alarms on 2026-08-27 left exactly one surviving record — each had eaten the previous. **Its only delivery path was a record read at the next session start.** The one component that detects an away-mode outage could not interrupt the component having it. It shouted sixteen times into a log file. It now routes through the existing wedge-alarm channels rather than a second parallel mechanism. ## Five review rounds, all of them finding real silence Every round found another way this thing could fail to report — which is worth stating plainly, because a monitor that fails quietly is worse than no monitor: - **Daemon liveness was machine-wide.** A sibling firstmate home with a healthy daemon would mask a dead local one, so the watchdog would never alarm — the exact failure it exists to catch, rebuilt inside itself. Now scoped to this home's own record. - **Nothing launched it.** The promoted script had no caller, so the untracked hand-started copy would have stayed what actually ran. A promotion nothing launches is not a promotion. - **The launcher wiring was race-able three ways** — it could start before the daemon wrote its pid file and false-alarm immediately; its check-then-launch was TOCTOU-raceable into a double launch; and it would have started a second watchdog beside a still-running legacy one, violating the constraint it was written to honour. Now claims its singleton atomically via its own lock, mirroring how the supervisor daemon already claims its own, and only ever attempts a launch on the fresh-start path. - **The heartbeat check was skipped entirely when the beat file was absent** — daemon alive but no beat file ever written meant no check ran at all and the cycle reported healthy forever. Grace is now anchored on a fact about the daemon rather than on when the watchdog happened to look, so a late-starting watchdog cannot hand a fresh grace period to a long-dead watcher. - **The alarm rate-limit marker survived an away-mode session boundary.** Alarm at 10:00, captain returns, re-enters at 10:20, daemon fails again — the 10:25 check saw a 25-minute-old marker and returned silently, with no alarm, no alert and not even a log line, for a brand-new failure in a brand-new session. It is now cleared as part of the same fresh-entry cleanup that already handles the escalation buffer and wedge marker; a mid-session refresh deliberately does **not** clear it, or the alarm storm the limit exists to prevent comes back. ## One known limitation, stated rather than engineered around A rapid exit-and-re-enter inside the legacy watchdog's own poll window can briefly run both copies. It is one-time and transitional — only possible while a pre-this-PR hand-started watchdog is alive — self-limiting, and cannot recur once every watchdog on a machine is lock-based. Fixing it would need either signalling the legacy process (forbidden) or a heuristic guess at whether one is running, and a guess like that outlives the problem and then misfires. Documented instead. ## Deliberately not fixed here A recycled pid can mask a dead lock owner, which would permanently disable whatever the lock guards. The general fix belongs at the shared lock helper — used by the daemon lock, the wake-queue lock and the watcher lock — and changing that primitive for one task's benefit is far too much blast radius. This PR uses a local identity check mirroring the pattern the supervisor daemon already proves. The general problem is filed separately and looks like the same root cause as upstream issue 3279. ## Tests The central one reproduces the sixteen-alarms-one-record loss and proves a pending wake now survives an alarm. Plus: a live foreign-home daemon with a dead local one must alarm; two rapid entries must produce exactly one watchdog; a refresh against an active legacy session must launch none; a new session's failure inside the rate-limit window must alarm while a mid-session refresh must not reset it. All fail against the pre-fix code. Pipeline passed clean — review, test, document, lint — and pushed at `c2f959ce`. `pr` and `ci` skipped because the tool has no Forgejo provider; this PR is what fires CI.
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.
no-mistakes(document): document the promoted away-mode watchdog and shared wedge-alarm lib
Some checks failed
CI / Test coverage guard (pull_request) Successful in 12s
CI / Behavior portable parallel 2 (pull_request) Successful in 2m54s
CI / Behavior portable parallel 1 (pull_request) Successful in 3m1s
CI / Lint shell scripts (pull_request) Successful in 5m13s
CI / Behavior portable serial 4 (pull_request) Successful in 10m9s
CI / Behavior portable serial 2 (pull_request) Successful in 12m39s
CI / Repo invariants (pull_request) Successful in 4s
CI / Behavior portable serial 3 (pull_request) Successful in 12m58s
CI / Behavior portable serial 1 (pull_request) Failing after 16m49s
CI / Behavior tests (Herdr) (pull_request) Successful in 7m17s
c2f959ce0b
ci: re-trigger after the hub DNS fix (pyr0-dev-infra 253de8ce)
All checks were successful
CI / Test coverage guard (pull_request) Successful in 13s
CI / Behavior portable parallel 2 (pull_request) Successful in 3m5s
CI / Behavior portable parallel 1 (pull_request) Successful in 3m13s
CI / Lint shell scripts (pull_request) Successful in 5m7s
CI / Behavior portable serial 4 (pull_request) Successful in 10m6s
CI / Behavior portable serial 2 (pull_request) Successful in 12m42s
CI / Repo invariants (pull_request) Successful in 5s
CI / Behavior portable serial 3 (pull_request) Successful in 12m51s
CI / Behavior portable serial 1 (pull_request) Successful in 17m12s
CI / Behavior tests (Herdr) (pull_request) Successful in 7m24s
4292eb95ad
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.
john closed this pull request 2026-09-05 05:21:54 +00:00
All checks were successful
CI / Test coverage guard (pull_request) Successful in 13s
CI / Behavior portable parallel 2 (pull_request) Successful in 3m5s
CI / Behavior portable parallel 1 (pull_request) Successful in 3m13s
CI / Lint shell scripts (pull_request) Successful in 5m7s
CI / Behavior portable serial 4 (pull_request) Successful in 10m6s
CI / Behavior portable serial 2 (pull_request) Successful in 12m42s
CI / Repo invariants (pull_request) Successful in 5s
CI / Behavior portable serial 3 (pull_request) Successful in 12m51s
CI / Behavior portable serial 1 (pull_request) Successful in 17m12s
CI / Behavior tests (Herdr) (pull_request) Successful in 7m24s

Pull request closed

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!22
No description provided.