Skip to content

feat(watchdog): launchd beacon backstop for watcher continuity - #1252

Open
hnvivek wants to merge 2 commits into
kunchenguid:mainfrom
hnvivek:fm/fm-watcher-watchdog
Open

feat(watchdog): launchd beacon backstop for watcher continuity#1252
hnvivek wants to merge 2 commits into
kunchenguid:mainfrom
hnvivek:fm/fm-watcher-watchdog

Conversation

@hnvivek

@hnvivek hnvivek commented Jul 29, 2026

Copy link
Copy Markdown

Intent

Build a launchd-backed beacon watchdog for firstmate's watcher continuity. Root cause: the Claude Stop asyncRewake auto-arm only re-arms while Claude is turning, so a watcher that dies silently during an idle gap leaves the liveness beacon stale with nothing to re-arm it (observed ~4h blind). The fix must be independent of Claude's turn cadence and survive firstmate restarts and reboots.

Deliverables: a checker script, a per-user launchd agent template (RunAtLoad + 90s StartInterval), an install/uninstall/status helper, a behavior test, a mechanism+install doc with a cross-reference from the existing watcher-continuity doc, and wiring in the scripts doc, the documentation-audiences inventory, and the test family mapping.

Key deliberate decisions and tradeoffs a reviewer should know:

  • The checker reuses the EXACT gates the Stop auto-arm uses (primary scope, supervision need, not-AFK, plus a live session lock), so it never arms an idle/away/unowned home. It only acts when the beacon age is past the grace window (default 300s), matching the system's liveness window.
  • Single-flight coordination: the checker acquires the SAME owner lock the Stop hook uses (the home-scoped auto-arm owner lock); a live holder (hook or a prior checker tick) makes it exit 0, so it never double-arms across the hook and the watchdog. It foregrounds the real arm wrapper (never shell ampersand), exactly like the hook; blocking for the arm cycle is what keeps the watcher and its fresh beacon alive between turns.
  • The launchd checker is NOT in a harness process tree, so unlike the hook it cannot prove it descends from the lock owner. It instead gates on a LIVE session-lock holder, and intentionally does NOT reclaim a stale lock (a restarted session re-acquires its own).
  • The checker NEVER writes the rewake outcome to the epoch ledger: that outcome lets the synchronous Stop guard allow a stop WITHOUT a Claude continuation, and a launchd arm produces no continuation, so writing it would falsely suppress one. The guard already recognizes a live owner-lock pid as recovery-owned, which is the correct signal while the checker holds the lock. The checker always exits 0 (never 2): a launchd exit carries no rewake.
  • Only the watcher process ever touches the beacon; the checker only reads its age.
  • The installer is macOS-only (fails loud if launchctl is unavailable), derives a per-home label so several homes under one user do not collide, and reloads idempotently.
  • AGENTS.md is intentionally untouched: the watchdog is opt-in operator install (operator-current docs), not an always-loaded agent-runtime trigger.

What Changed

  • Added a launchd-backed beacon watchdog: fm-watchdog-check.sh reuses the Stop auto-arm's exact gates (primary scope, supervision need, not-AFK, live session lock) and re-arms a watcher when the liveness beacon ages past its grace window, plus a RunAtLoad + 90s StartInterval LaunchAgent template and a macOS-only fm-watchdog-install.sh (install/uninstall/status).
  • Wired the watchdog into docs and the test inventory: new docs/watcher-watchdog.md mechanism+install doc cross-referenced from docs/watcher-continuity.md, with entries in docs/scripts.md, docs/documentation-audiences.json, and the test family mapping, plus a fm-watchdog-check.test.sh behavior suite.
  • Bundled supporting fixes carried on the branch: spawn forwards CLAUDE_CONFIG_DIR to crewmates, session-lock resolves ancestry to the outermost Claude pid, and fm-brief.sh parses under stock macOS Bash 3.2.

Risk Assessment

✅ Low: The one substantive finding (X-mode cadence) from round 1 was fixed correctly by mirroring the Stop auto-arm's x-mode.env sourcing; the watchdog feature and the surrounding fixes are otherwise clean, well-tested, and conform to the stated intent.

Testing

Ran the full behavior suite (12/12 pass) which exercises the real checker against hermetic fixtures covering every gate and the single-flight invariant. Since the suite only greps (never runs) the installer, I additionally drove the real installer install→status→uninstall under a fake launchctl shim and proved the stamped LaunchAgent is valid (plutil -lint: OK, RunAtLoad=true, StartInterval=90, FM_HOME, real checker path, per-home label). Confirmed exact gate parity with the Stop auto-arm and the absence of both forbidden behaviors (epoch-ledger write, exit 2). All documentation deliverables and cross-references are in place; AGENTS.md is correctly untouched. No issues found.

Evidence: Stamped, validated launchd agent produced by the installer

plutil -lint stamped-agent.plist → OK; carries Label, /bin/bash + fm-watchdog-check.sh, FM_HOME, RunAtLoad <true/>, StartInterval <integer>90</integer>, log paths

<?xml version="1.0" encoding="UTF-8"?>
<!--
  launchd per-user agent template for the firstmate watcher watchdog.

  This is a TEMPLATE, not a loadable plist: bin/fm-watchdog-install.sh stamps the
  com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1, /Users/vivek/.no-mistakes/worktrees/052c8ba410de/01KYQ6R9KMT0FK96XSZV0Y0H8G/bin/fm-watchdog-check.sh, and /private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1 placeholders with this home's real
  absolute paths and a per-home unique label, then loads the result from
  ~/Library/LaunchAgents. One stamped copy per firstmate home keeps several homes
  under one user from colliding. See docs/watcher-watchdog.md.

  RunAtLoad arms once at login/reboot so a home with in-flight work is never left
  blind across a restart; StartInterval re-checks the beacon on a short timer so a
  watcher that dies silently mid-gap is restored within the grace window,
  independent of any harness turn cadence.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/vivek/.no-mistakes/worktrees/052c8ba410de/01KYQ6R9KMT0FK96XSZV0Y0H8G/bin/fm-watchdog-check.sh</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>FM_HOME</key>
    <string>/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1</string>
  </dict>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>90</integer>
  <key>StandardOutPath</key>
  <string>/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1/state/watchdog.out.log</string>
  <key>StandardErrorPath</key>
  <string>/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1/state/watchdog.err.log</string>
</dict>
</plist>
Evidence: Installer install/status/uninstall transcript (fake launchctl, sandboxed HOME)
########## INSTALL ##########
watchdog: installed and loaded label=com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1 home=/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1
watchdog: status with: FM_HOME=/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1 bin/fm-watchdog-install.sh status
########## STATUS ##########
watchdog home:  /private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1
watchdog label: com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1
watchdog plist: /var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/Library/LaunchAgents/com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1.plist (present)
watchdog loaded: yes
########## launchctl calls ##########
CALL: bootout gui/501/com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1
CALL: bootstrap gui/501 <SANDBOX>/Library/LaunchAgents/com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1.plist
CALL: list com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1
########## stamped plist content ##########
<?xml version="1.0" encoding="UTF-8"?>
<!--
  launchd per-user agent template for the firstmate watcher watchdog.

  This is a TEMPLATE, not a loadable plist: bin/fm-watchdog-install.sh stamps the
  com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1, /Users/vivek/.no-mistakes/worktrees/052c8ba410de/01KYQ6R9KMT0FK96XSZV0Y0H8G/bin/fm-watchdog-check.sh, and /private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1 placeholders with this home's real
  absolute paths and a per-home unique label, then loads the result from
  ~/Library/LaunchAgents. One stamped copy per firstmate home keeps several homes
  under one user from colliding. See docs/watcher-watchdog.md.

  RunAtLoad arms once at login/reboot so a home with in-flight work is never left
  blind across a restart; StartInterval re-checks the beacon on a short timer so a
  watcher that dies silently mid-gap is restored within the grace window,
  independent of any harness turn cadence.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/vivek/.no-mistakes/worktrees/052c8ba410de/01KYQ6R9KMT0FK96XSZV0Y0H8G/bin/fm-watchdog-check.sh</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>FM_HOME</key>
    <string>/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1</string>
  </dict>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>90</integer>
  <key>StandardOutPath</key>
  <string>/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1/state/watchdog.out.log</string>
  <key>StandardErrorPath</key>
  <string>/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1/state/watchdog.err.log</string>
</dict>
</plist>
########## plutil lint ##########
/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/no-mistakes-evidence/01KYQ6R9KMT0FK96XSZV0Y0H8G/stamped-agent.plist: OK
########## UNINSTALL ##########
watchdog: uninstalled label=com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1 home=/private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1
########## status after uninstall ##########
watchdog home:  /private/var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/home1
watchdog label: com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1
watchdog plist: /var/folders/jh/bb35jdhn0dv1wrsclqx1n4t40000gn/T/fm-wd-sandbox.aX7w0fyWD6/Library/LaunchAgents/com.firstmate.watcher-watchdog.private-var-folders-jh-bb35jdhn0dv1wrsclqx1n4t40000gn-T-fm-wd-sandbox-aX7w0fyWD6-home1.plist (absent)
watchdog loaded: yes

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⚠️ **Rebase** - 1 warning

Push main to origin, or rebase your branch onto origin/main, before gating.

🔧 Fix applied.
1 warning still open:

Push main to origin, or rebase your branch onto origin/main, before gating.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ bin/fm-watchdog-check.sh:116 - The watchdog checker arms a watcher for X-mode relay-poll homes (its fm_supervision_needed gate admits any home with state/x-watch.check.sh) but never sources config/x-mode.env before running the arm. The Stop auto-arm sources it ([ -f &#34;$CONFIG/x-mode.env&#34; ] &amp;&amp; . &#34;$CONFIG/x-mode.env&#34; in bin/fm-claude-stop-autoarm.sh:131) because it exports FM_CHECK_INTERVAL=30, which bin/fm-watch.sh:98 consumes (CHECK_INTERVAL=${FM_CHECK_INTERVAL:-300}). The arm itself does not source it. Result: when the watchdog re-arms an X-mode home during an idle gap, the forked watcher runs at the 300s default cadence instead of X-mode's 30s — a 10x relay-poll slowdown for that mode, and an inconsistency with the intent's stated 'foregrounds the real arm wrapper ... exactly like the hook.' Fix: resolve CONFIG=&#34;${FM_CONFIG_OVERRIDE:-$FM_HOME/config}&#34; and add [ -f &#34;$CONFIG/x-mode.env&#34; ] &amp;&amp; . &#34;$CONFIG/x-mode.env&#34; immediately before invoking $WATCH_ARM, mirroring the hook.

🔧 Fix: source x-mode.env in watchdog checker before arming
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-watchdog-check.test.sh (12 behavior tests: re-arm when beacon absent/old-mtime, no-op when fresh/idle/AFK/no-live-session-lock/child-worktree/single-flight-held, second-tick-doesn't-double-arm, installer+plist contract, checker never exits 2 / foreground arm)
  • Manual end-to-end installer drive with a fake launchctl shim against a fixture primary home: fm-watchdog-install.sh install|status|uninstall; captured the stamped LaunchAgent and validated it with plutil -lint (OK)
  • Gate-parity check: compared checker gate calls against bin/fm-claude-stop-autoarm.sh (same scope/need/AFK/single-flight-owner-lock state/.claude-autoarm.lock, same foreground fm-watch-arm.sh, same x-mode.env sourcing)
  • Forbidden-behavior grep on the checker: no epoch-ledger write, no exit 2
  • Doc/cross-reference verification: docs/watcher-watchdog.md present, cross-ref from watcher-continuity.md, wiring in docs/scripts.md, entry in docs/documentation-audiences.json, family mapping in bin/fm-test-run.sh, AGENTS.md intentionally untouched
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

hnvivek added 2 commits July 29, 2026 11:31
Add a turn-independent continuity layer that re-arms a silently-dead
watcher on a launchd timer, closing the gap where a watcher dies during
an idle gap with no Stop hook to re-arm it and state/.last-watcher-beat
goes stale for hours.

- bin/fm-watchdog-check.sh: checker reusing the Stop auto-arm's gates
  (primary scope, supervision need, not-AFK, live session lock) plus the
  shared single-flight owner lock (state/.claude-autoarm.lock), so it
  never double-arms with the hook and only acts when the beacon is past
  grace. It foregrounds the real arm wrapper (never shell &) and never
  writes outcome=rewake, since a launchd arm produces no Claude
  continuation and that outcome would falsely suppress one. A launchd
  process tree is not a harness ancestry, so it gates on a live lock
  holder rather than owned-by-self.
- launchd/com.firstmate.watcher-watchdog.plist: per-user agent template
  (RunAtLoad, 90s StartInterval) the installer stamps per home.
- bin/fm-watchdog-install.sh: stamp/load/unload + status; per-home label
  so homes under one user do not collide; idempotent reload; fails loud
  off-macOS.
- tests/fm-watchdog-check.test.sh: re-arm vs no-op (fresh beacon, each
  gate, held single-flight) and never-double-arm concurrency, plus the
  installer and plist-template contract.
- docs/watcher-watchdog.md with a cross-reference from
  watcher-continuity.md; scripts.md, documentation-audiences.json, and
  the watcher-wake-lock test family wiring.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant