Skip to content

fix(bin): stop false watcher cycle-failure alarms and supervision churn - #1416

Open
Moshik21 wants to merge 4 commits into
kunchenguid:mainfrom
Moshik21:fm/fm-watcher-stale-churn-r2
Open

fix(bin): stop false watcher cycle-failure alarms and supervision churn#1416
Moshik21 wants to merge 4 commits into
kunchenguid:mainfrom
Moshik21:fm/fm-watcher-stale-churn-r2

Conversation

@Moshik21

Copy link
Copy Markdown

Intent

Stop the firstmate primary watcher from repeatedly reporting 'watcher: FAILED - cycle ended without an actionable reason' and needing a manual --restart, and stop the related supervision churn found while diagnosing it.

Investigation (report at data/fm-watcher-stale-churn/report.md) established that the alarm was almost entirely FALSE. Real wakes were being delivered the whole time; what failed was an ATTACHED duplicate arm's classification of an ordinary close. An attached arm can never see the owner arm's stdout, so every ordinary actionable close read as a reasonless death to the duplicate observer, and the failure banner then told the model to arm manually, creating the next duplicate and the next false alarm.

Fixes accepted for this change, F1-F6, already committed in the first commit:
F1 - the attached arm consults the lifecycle ledger before failing: an owner-delivered actionable close reports a typed benign line and exits 0, while a close with no delivered reason keeps the typed failure.
F2 - the Claude Stop hook's failure banner defers to its own next-Stop re-arm and gates manual repair on the following turn still showing no watcher, breaking the duplicate-creating feedback loop.
F3 - the watcher's poll and signal-grace waits are trap-interruptible (backgrounded sleep plus wait), so a TERM acts at once, and --restart refuses loudly when a predecessor outlives its bounded reap wait rather than forking a child that would attach to the dying holder.
F4 - a live-agent declared pause caches its authoritative verdict for the recheck window, so the costly crew-state and backend probes run once per window instead of once per ~20s poll. This is the pause-path churn the triage log showed.
F5 - secondmate windows are exempt from the stale loop unconditionally, per the AGENTS.md section 8 contract that a secondmate's idle endpoint is healthy and parent supervision relies on its routed status, not a pane-stale cadence.
F6 - a declared pause whose endpoint is gone with a confirmed-dead agent surfaces once and keeps its bounded recheck instead of dropping out of supervision silently.

A review gate on that first commit raised four findings; the captain decided all four and this second commit applies exactly that decision:
A (FIX, applied) - F3's loud refusal was a regression on event-capable (herdr) homes. The reap wait was a fixed 5s, but on an event-capable backend the terminal wait of a cycle is a foreground command substitution budgeted at FM_POLL (default 15s), and bash runs no trap while a foreground command is outstanding, so an ordinary event-path restart would routinely hit the refusal. The bound is now derived from the poll budget plus a 5s margin, floored at the historical 5s, with FM_ARM_REAP_WAIT to override. Deliberately narrow: only the bound moved; the refusal keeps its shape, typed line, ledger row and exit code. Option A2 (making the event-path wait interruptible) was rejected as reaching into backend wait semantics shared with the push path.
B (DEFER, deliberately untouched) - the changed-hash branch still calls clear_pause_tracking, so F4's throttle is defeated on a pane that redraws between polls. Open PR #1389 owns exactly that case in another lane; touching those lines here would conflict. F4 still delivers its win on a static parked pane. Do not flag this as an incomplete fix.
C (DEFER, deliberately out of scope) - F1's typed benign close line is interpreted by the Claude Stop hook; the OpenCode and Pi arm extensions still fall through to a generic failure for a clean close with no recognized wake line. Extending those two adapters is new harness surface, filed as its own backlog item, not part of this change.
D (DOCUMENT, applied) - F1's classification now depends on a ledger row whose append is non-blocking and gives up under lock contention. The arm header previously claimed the ledger was never a supervision dependency. It is corrected to say the ledger is load-bearing for exactly that one classification, and the fallback is stated: a dropped row degrades to the typed failure line, costing a spurious failure report but never a missed wake, because wakes are enqueued durably before the watcher prints and exits. Making the append blocking was rejected as introducing lock waiting on the close path for a smaller cost than the risk.
Also applied: mechanical removal of the secondmate stale-triage branch and the pause-path kind guard that F5's unconditional skip left unreachable.

Constraints honored: no behavior change to the non-blocking ledger append; the never-exits refusal test still passes; the new reap-bound regression test was verified to FAIL against the old fixed 5s bound and pass with the fix; docs/watcher-continuity.md is updated for both the derived bound and the ledger dependency.

What Changed

  • bin/fm-watch-arm.sh: an attached arm whose watched cycle ends with no successor now consults the lifecycle ledger before failing — an owner-delivered actionable close prints the typed benign watcher: cycle closed actionably (reason delivered by its owner arm) and exits 0, while a close with no delivered reason keeps the typed failure. --restart refuses loudly (typed FAILED, exit 1, no fresh fork) when a TERM'd predecessor outlives its reap wait, and that wait is now derived as max(FM_POLL, 2 * FM_CREW_STATE_NM_TIMEOUT) + 5 seconds, floored at the historical 5s, with a new FM_ARM_REAP_WAIT override, so it covers both non-interruptible foreground spans a stopping watcher can be parked in.
  • bin/fm-watch.sh: poll and signal-grace waits run through a backgrounded sleep plus wait so a TERM acts immediately; a declared pause caches its authoritative verdict in the recheck marker so crew-state and backend probes run once per recheck window instead of once per poll; secondmate windows skip the stale loop unconditionally; and a declared pause whose endpoint no longer captures with a confirmed-dead agent surfaces once and keeps its bounded recheck instead of dropping out of supervision. The now-unreachable secondmate stale branch and pause-path kind guards were removed. bin/fm-claude-stop-autoarm.sh's failure banner now defers to the hook's own next-Stop re-arm and gates manual repair on the following turn still showing no watcher.
  • Docs (docs/watcher-continuity.md, docs/architecture.md, docs/configuration.md, docs/supervision-protocols/claude.md, AGENTS.md) record the derived reap bound, the new benign close line, the unconditional secondmate exemption, the gone-endpoint recheck, and the correction that the non-blocking ledger is load-bearing for exactly that one classification (a dropped row degrades to the typed failure, never a missed wake). Tests added across tests/fm-watcher-lock.test.sh, tests/fm-watch-triage.test.sh, and tests/fm-claude-stop-autoarm.test.sh; the reap-bound regression test was verified to fail against the previous fixed bound.

Risk Assessment

✅ Low: The follow-up commit applies exactly the three accepted round-1 fixes within their authorized scope — a widened but shape-preserving reap bound, an equivalence-preserving dead-branch collapse, and a verified-safe removal of a redundant key recompute — with the matching doc sentence updated and both named reap-bound tests still satisfied by construction.

Testing

Ran the three targeted suites the change touches — watcher lock (34 cases), watch triage (43), Claude Stop auto-arm (17) — all green, then proved each accepted fix at the product level by driving the real scripts at the base, both intermediate, and target commits and diffing what an operator actually sees: the attached duplicate arm's false watcher: FAILED - cycle ended without an actionable reason is replaced by the typed benign owner-delivered close with exit 0, the Stop-hook alarm no longer tells the model to arm immediately, an event-path --restart now reaps and starts a successor instead of refusing, steady-state crew-state reads for one parked crewmate fall from 16 per 20s to 0, and a paused secondmate pane goes silent while its routed status still wakes. The second commit's widening of the reap bound to cover the crew-state span shipped without coverage, so I added a focused regression test and confirmed it fails against the pre-widening commit with the exact refusal line and passes at HEAD; it adds a case rather than altering any existing assertion. No visual artifact applies — every surface here is a CLI/daemon line, so the transcripts are the end-user view. Worktree is clean apart from that intentional test addition.

Evidence: Before/after transcripts: duplicate-arm false failure, Stop-hook alarm, restart reap bound, pause churn, secondmate pane
firstmate watcher: false cycle-failure alarm + supervision churn - before/after transcripts
before = base 3772964   after = target 09caada   (finding A rows also show the two intermediate commits)

===============================================================================
1. THE REPORTED SYMPTOM (F1): a duplicate arm attached to a healthy watcher
   sees an ordinary crewmate finish close the cycle.
===============================================================================
----- BEFORE -----
### owner arm (started the watcher)   exit=0
    watcher: started pid=215175 (beacon fresh)
    signal: /tmp/fm-dup-arm-ev-before-YRzf/state/task.status
### duplicate arm (ATTACHED to it)    exit=1
    watcher: attached pid=215175 (beacon 0s)
    watcher: FAILED - cycle ended without an actionable reason
### lifecycle ledger rows for this cycle
    arm_pid=215160	watcher_pid=215175	origin=started	started_at=1785527306	ended_at=1785527308	exit_code=0	signal=none	reason=actionable-signal	beacon_age=1	lock_before=pid:215175|identity:linux-starttime=1011191 cmdline-hex=62617368002f746d702f666d2d626173652d4c3355672f62696e2f666d2d77617463682e736800	lock_after=pid:none|identity:none	successor=none
    arm_pid=215326	watcher_pid=215175	origin=attached	started_at=1785527306	ended_at=1785527310	exit_code=unknown	signal=unknown	reason=attached-cycle-ended	beacon_age=3	lock_before=pid:215175|identity:linux-starttime=1011191 cmdline-hex=62617368002f746d702f666d2d626173652d4c3355672f62696e2f666d2d77617463682e736800	lock_after=pid:none|identity:none	successor=none
----- AFTER  -----
### owner arm (started the watcher)   exit=0
    watcher: started pid=216080 (beacon fresh)
    signal: /tmp/fm-dup-arm-ev-after-bttC/state/task.status
### duplicate arm (ATTACHED to it)    exit=0
    watcher: attached pid=216080 (beacon 0s)
    watcher: cycle closed actionably (reason delivered by its owner arm)
### lifecycle ledger rows for this cycle
    arm_pid=216064	watcher_pid=216080	origin=started	started_at=1785527310	ended_at=1785527312	exit_code=0	signal=none	reason=actionable-signal	beacon_age=1	lock_before=pid:216080|identity:linux-starttime=1011602 cmdline-hex=62617368002f686f6d652f6b6f6e6e652f2e6e6f2d6d697374616b65732f776f726b74726565732f3739366632306335616638312f30314b5957504d3534574d374b453536434a4a324558463935362f62696e2f666d2d77617463682e736800	lock_after=pid:none|identity:none	successor=none
    arm_pid=216231	watcher_pid=216080	origin=attached	started_at=1785527310	ended_at=1785527314	exit_code=unknown	signal=unknown	reason=attached-cycle-owner-delivered	beacon_age=3	lock_before=pid:216080|identity:linux-starttime=1011602 cmdline-hex=62617368002f686f6d652f6b6f6e6e652f2e6e6f2d6d697374616b65732f776f726b74726565732f3739366632306335616638312f30314b5957504d3534574d374b453536434a4a324558463935362f62696e2f666d2d77617463682e736800	lock_after=pid:none|identity:none	successor=none

===============================================================================
2. THE FEEDBACK LOOP (F2): the alarm the Claude Stop hook puts in front of the
   model when a cycle does close with a typed failure.
===============================================================================
----- BEFORE -----
firstmate watcher cycle FAILED - supervision is down while this home still needs it.
watcher: FAILED - no live watcher with a fresh beacon
Run bin/fm-wake-drain.sh first. Then repair supervision with bin/fm-watch-arm.sh as its own Claude Code background task (never shell &). If the failure repeats, treat it as a blocker and report it instead of ending blind.
### hook exit=2 (2 = rewake the model with this alarm)
----- AFTER  -----
firstmate watcher cycle FAILED - no confirmed live watcher right now, and this home still needs one.
watcher: FAILED - no live watcher with a fresh beacon
Run bin/fm-wake-drain.sh first and handle anything queued. This Stop hook re-arms automatically at the end of this turn - do NOT start bin/fm-watch-arm.sh now, because a second arm attached to the same cycle is what turns an ordinary delivered wake into this false failure. Only if the next turn still shows no live watcher, repair with bin/fm-watch-arm.sh as its own Claude Code background task (never shell &); if that also fails, treat it as a blocker and report it instead of ending blind.
### hook exit=2 (2 = rewake the model with this alarm)

===============================================================================
3. RESTART REFUSAL BOUND (review finding A + this commit's widening)
===============================================================================
--- 3a. event-path home: FM_POLL=30, predecessor answers TERM after 6s ---
----- first commit de67e55 (fixed 5s bound) -----
### predecessor pid=217683 stays TERM-deaf for 6000ms; FM_POLL=30 FM_CREW_STATE_NM_TIMEOUT=10
### bin/fm-watch-arm.sh --restart said:
    watcher: FAILED - predecessor watcher pid 217683 is still stopping; no new watcher was started - retry --restart after it exits
### outcome: no replacement watcher; the lock still names the old pid 217683
----- target 09caada -----
### predecessor pid=218153 stays TERM-deaf for 6000ms; FM_POLL=30 FM_CREW_STATE_NM_TIMEOUT=10
### bin/fm-watch-arm.sh --restart said:
    watcher: started pid=218448 (beacon fresh)
### outcome: a FRESH watcher (pid=218448) now holds the singleton lock
--- 3b. short-poll home parked in the crew-state span: FM_POLL=1, FM_CREW_STATE_NM_TIMEOUT=10, TERM answered after 8s ---
----- second commit 66f8df0 (bound from FM_POLL alone = 6s) -----
### predecessor pid=218763 stays TERM-deaf for 8000ms; FM_POLL=1 FM_CREW_STATE_NM_TIMEOUT=10
### bin/fm-watch-arm.sh --restart said:
    watcher: FAILED - predecessor watcher pid 218763 is still stopping; no new watcher was started - retry --restart after it exits
### outcome: no replacement watcher; the lock still names the old pid 218763
----- target 09caada (bound = max(FM_POLL, 2*FM_CREW_STATE_NM_TIMEOUT)+5 = 25s) -----
### predecessor pid=219430 stays TERM-deaf for 8000ms; FM_POLL=1 FM_CREW_STATE_NM_TIMEOUT=10
### bin/fm-watch-arm.sh --restart said:
    watcher: started pid=224097 (beacon fresh)
### outcome: a FRESH watcher (pid=224097) now holds the singleton lock

===============================================================================
4. PAUSE-PATH CHURN (F4) and SECONDMATE PANE EXEMPTION (F5)
===============================================================================
--- 4a. recurring cost of one parked crewmate ---
----- BEFORE -----
### first sight: stale: test:fm-parked (cost 1 authoritative read)
### one parked crewmate, 20s of supervision at FM_POLL=1s, nothing changed
    authoritative crew-state reads spent: 16
    watcher output during the window:     (silent, as it should be)
----- AFTER  -----
### first sight: stale: test:fm-parked (cost 1 authoritative read)
### one parked crewmate, 20s of supervision at FM_POLL=1s, nothing changed
    authoritative crew-state reads spent: 0
    watcher output during the window:     (silent, as it should be)
--- 4b. a paused secondmate's idle pane ---
----- BEFORE -----
### 12s watching a paused secondmate's idle pane (past its recheck cadence)
    watcher woke the captain about the pane: stale: test:fm-secondmate-held (paused 500s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
----- AFTER  -----
### 12s watching a paused secondmate's idle pane (past its recheck cadence)
    watcher stayed silent about the pane (secondmate panes are not pane-supervised)
    after a routed status write: signal: /tmp/fm-secondmate-pane-ev-after-NV3P/state/secondmate-held.status
Evidence: The reported symptom, before vs after (duplicate arm on an ordinary crewmate finish)
----- BEFORE (base 3772964) -----
### owner arm (started the watcher) exit=0
watcher: started pid=215175 (beacon fresh)
signal: .../state/task.status
### duplicate arm (ATTACHED to it) exit=1
watcher: attached pid=215175 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason
ledger: reason=attached-cycle-ended

----- AFTER (target 09caada) -----
### owner arm (started the watcher) exit=0
watcher: started pid=216080 (beacon fresh)
signal: .../state/task.status
### duplicate arm (ATTACHED to it) exit=0
watcher: attached pid=216080 (beacon 0s)
watcher: cycle closed actionably (reason delivered by its owner arm)
ledger: reason=attached-cycle-owner-delivered
Evidence: The Stop-hook alarm the model reads on a typed failure
----- BEFORE -----
firstmate watcher cycle FAILED - supervision is down while this home still needs it.
watcher: FAILED - no live watcher with a fresh beacon
Run bin/fm-wake-drain.sh first. Then repair supervision with bin/fm-watch-arm.sh as its own Claude Code background task (never shell &). If the failure repeats, treat it as a blocker and report it instead of ending blind.
### hook exit=2

----- AFTER -----
firstmate watcher cycle FAILED - no confirmed live watcher right now, and this home still needs one.
watcher: FAILED - no live watcher with a fresh beacon
Run bin/fm-wake-drain.sh first and handle anything queued. This Stop hook re-arms automatically at the end of this turn - do NOT start bin/fm-watch-arm.sh now, because a second arm attached to the same cycle is what turns an ordinary delivered wake into this false failure. Only if the next turn still shows no live watcher, repair with bin/fm-watch-arm.sh as its own Claude Code background task (never shell &); if that also fails, treat it as a blocker and report it instead of ending blind.
### hook exit=2
Evidence: Restart reap bound: finding A's regression, and this commit's crew-state widening
3a. event-path home: FM_POLL=30, predecessor answers TERM after 6s
first commit de67e55 (fixed 5s bound):
watcher: FAILED - predecessor watcher pid 217683 is still stopping; no new watcher was started - retry --restart after it exits
outcome: no replacement watcher; the lock still names the old pid
target 09caada:
watcher: started pid=218448 (beacon fresh)
outcome: a FRESH watcher now holds the singleton lock

3b. short-poll home parked in the crew-state span: FM_POLL=1, FM_CREW_STATE_NM_TIMEOUT=10, TERM answered after 8s
second commit 66f8df0 (bound from FM_POLL alone = 6s):
watcher: FAILED - predecessor watcher pid 218763 is still stopping; no new watcher was started - retry --restart after it exits
target 09caada (bound = max(FM_POLL, 2*FM_CREW_STATE_NM_TIMEOUT)+5 = 25s):
watcher: started pid=224097 (beacon fresh)
Evidence: Supervision churn and secondmate pane noise
4a. recurring cost of ONE parked crewmate (20s at FM_POLL=1, nothing changed on the pane)
BEFORE: authoritative crew-state reads spent: 16
AFTER: authoritative crew-state reads spent: 0
(both silent, and both paid the same single read on first sight)

4b. a paused secondmate's idle pane, 12s past its recheck cadence
BEFORE: stale: test:fm-secondmate-held (paused 500s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
AFTER: watcher stayed silent about the pane
after a routed status write: signal: .../state/secondmate-held.status
Evidence: New regression test verified failing against the pre-widening commit 66f8df0
$ cd <66f8df0 checkout> && bash tests/fm-watcher-lock.test.sh
ok - watch restart refuses to signal a reused pid
ok - watch restart refuses a predecessor that outlives the reap wait instead of attaching to it
ok - watch restart reaps a predecessor that outlives the old fixed bound but exits within the derived one
not ok - restart refused a predecessor parked in the crew-state span with FM_POLL=1: watcher: FAILED - predecessor watcher pid 135563 is still stopping; no new watcher was started - retry --restart after it exits

$ bin/fm-test-run.sh tests/fm-watcher-lock.test.sh # at target 09caada
ok - watch restart reaps a predecessor parked in the crew-state span even when FM_POLL is short
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ bin/fm-watch-arm.sh:107 - REAP_WAIT is derived as FM_POLL + 5 to cover the event-capable terminal wait, but that is not the only non-interruptible foreground span in a watcher cycle. crew_absorb_class (bin/fm-classify-lib.sh:337) invokes fm-crew-state.sh in a command substitution bounded by FM_CREW_STATE_NM_TIMEOUT (default 10s, bin/fm-crew-state.sh:73) and can chain a second bounded no-mistakes call; bash defers the TERM trap for that whole span, and its duration does not scale with FM_POLL. At the default FM_POLL=15 the 20s bound covers it, but FM_POLL is documented as user-tunable (docs/configuration.md:435) and any value below ~6 puts the derived bound under the crew-state budget, reintroducing exactly the spurious 'predecessor still stopping' refusal that decision A was applied to remove. Secondary: the bound is computed from the arm's FM_POLL, which need not match the environment the running predecessor was launched with. Consider flooring the derivation at the crew-state read budget as well (e.g. max(FM_POLL, FM_CREW_STATE_NM_TIMEOUT) + margin) rather than FM_POLL alone.
  • ℹ️ bin/fm-watch.sh:455 - The *) rm -f &#34;$recheck_file&#34; arm of the case at 453-456 is now unreachable. crew_absorb_class only ever returns working|paused|none; working already returned at 445-448, and lines 451-452 force the remainder into exactly {paused, none}, so paused|none) always matches. The case can collapse to a single unconditional printf &#39;%s&#39; &#34;$class&#34; &gt; &#34;$recheck_file&#34;.
  • ℹ️ bin/fm-watch.sh:939 - The new rm -f &#34;$STATE/.paused-gone-$key&#34; at line 937 consumes $key, which is only set by the bash-expansion block at 913-915; line 939 then recomputes the identical value via tr &#39;:/.&#39; &#39;___&#39;. The two forms are equivalent, so the code is correct, but reading a variable one line before it is reassigned is misleading and makes the new line look order-dependent on a value that has not been computed yet. Drop the recompute at 939 (or move it above 937).

🔧 Fix: widen reap bound to cover crew-state span; drop dead code
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh tests/fm-watcher-lock.test.sh (34 cases, exit 0; includes the never-exits refusal test and the FM_POLL-derived reap-bound regression)
  • bin/fm-test-run.sh tests/fm-watch-triage.test.sh (43 cases, exit 0; covers the F4 pause throttle, F5 secondmate exemption, F6 gone-endpoint recheck)
  • bin/fm-test-run.sh tests/fm-claude-stop-autoarm.test.sh (17 cases, exit 0; covers the F2 banner rewording)
  • Added test_watch_restart_reap_bound_covers_crew_state_wait to tests/fm-watcher-lock.test.sh:545 — the crew-state half of the widened bound had no coverage
  • Verified the new test FAILS against the pre-widening tree: git archive 66f8df0 into a temp checkout, copy the new test file in, bash tests/fm-watcher-lock.test.shnot ok - restart refused a predecessor parked in the crew-state span with FM_POLL=1
  • Manual end-to-end: scenario-duplicate-arm.sh — owner arm starts a real watcher, a second arm attaches, a done: status closes the cycle; compared the duplicate arm's stdout, exit code, and lifecycle-ledger reason at base 3772964 vs target 09caada
  • Manual end-to-end: scenario-stop-hook-banner.sh — drove bin/fm-claude-stop-autoarm.sh as a child of a fake harness holding the session lock with a typed-failure arm fixture, rendering the exact exit-2 alarm before and after
  • Manual end-to-end: scenario-restart-reap.shbin/fm-watch-arm.sh --restart against a TERM-deaf predecessor; FM_POLL=30/6s span at de67e55 vs HEAD, and FM_POLL=1 FM_CREW_STATE_NM_TIMEOUT=10/8s span at 66f8df0 vs HEAD
  • Manual end-to-end: scenario-pause-churn.sh — counted fm-crew-state.sh invocations over 20s of steady supervision of one static parked pane at FM_POLL=1, base vs HEAD
  • Manual end-to-end: scenario-secondmate-pane.sh — 12s watching a paused secondmate pane past its recheck cadence, then a routed status append, base vs HEAD
  • Code check that the constraint held: git diff 3772964..HEAD -- bin/fm-watch-arm.sh shows cycle_log_append mechanics unchanged (new callers only), and the removed pause_state_class case / duplicate key= recompute are provably unreachable/redundant
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Moshik21 added 4 commits July 31, 2026 11:08
An attached arm can never see the owner arm's stdout, so every ordinary
actionable close read as "cycle ended without an actionable reason" to a
duplicate observer, and the failure banner then told the model to arm
manually, recreating the duplicate that caused the next false alarm.

- The attached arm now consults the lifecycle ledger before failing: an
  owner-delivered actionable close reports a typed benign line and exits
  0, while a close with no delivered reason keeps the typed failure.
- The Stop hook's failure banner defers to its own next-Stop re-arm and
  gates manual repair on the following turn still showing no watcher.
- The watcher's poll and signal-grace waits are trap-interruptible, so a
  TERM acts at once instead of after a full wait, and --restart refuses
  loudly when a predecessor outlives its bounded reap wait rather than
  forking a child that would attach to the dying holder.
- A live-agent declared pause caches its authoritative verdict for the
  recheck window, so the costly crew-state and backend probes run once
  per window instead of once per poll.
- Secondmate windows are exempt from the stale loop unconditionally, per
  the AGENTS.md section 8 routed-status contract.
- A declared pause whose endpoint is gone with a confirmed-dead agent
  surfaces once and keeps its bounded recheck instead of dropping out of
  supervision silently.
The bounded wait for a TERM'd predecessor was a fixed 5 seconds, shorter
than the 15 second default poll budget. On an event-capable backend the
terminal wait of a cycle is a foreground command substitution budgeted at
FM_POLL, and bash runs no trap while a foreground command is outstanding,
so a TERM'd watcher parked there cannot exit before that budget elapses.
The loud refusal added for a genuinely wedged predecessor therefore fired
on ordinary event-path restarts, which then needed a retry.

- Derive the reap bound from the watcher's own poll budget plus a margin,
  never below the historical 5 seconds, with FM_ARM_REAP_WAIT to override
  it. The refusal keeps its shape, typed line, ledger row, and exit code;
  only the bound moves.
- Correct the arm header's ledger contract: the lifecycle ledger is
  load-bearing for exactly one classification, the attached arm's
  owner-delivered-close check, and a row dropped under lock contention
  degrades that check to the typed failure line, costing a spurious
  failure report but never a missed wake.
- Remove the secondmate stale-triage branch and the pause-path kind guard
  that the unconditional secondmate skip left unreachable.
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