Skip to content

fix(bin): keep confirming a slow watcher start while it makes progress - #7873

Open
kova1max wants to merge 6 commits into
kunchenguid:mainfrom
kova1max:fm/up-arm-confirm-timeout
Open

kova1max wants to merge 6 commits into
kunchenguid:mainfrom
kova1max:fm/up-arm-confirm-timeout

Conversation

@kova1max

@kova1max kova1max commented Oct 27, 2026 •

Copy link
Copy Markdown

Intent

"do all and create mrs to the upstream" - send each of these firstmate improvements upstream to kunchenguid/firstmate as its own PR from the public fork kova1max/firstmate.
This one: on a heavily loaded Mac (load 20+ on 10 cores) the Stop-hook watcher auto-arm failed with reason confirmation-timeout after 2 attempts because bin/fm-watch-arm.sh waits only FM_ARM_CONFIRM_TIMEOUT=10 s (30 on MSYS) for a fresh watcher beacon; supervision stayed down until a home set FM_ARM_CONFIRM_TIMEOUT=30 in config/watch.env.

What Changed

  • bin/fm-watch-arm.sh no longer gives up on a fresh watcher after one FM_ARM_CONFIRM_TIMEOUT window. If the watcher used CPU during that window, the arm opens another window (CPU time is read from /proc/<pid>/stat, or from ps where /proc is missing). This repeats until FM_GUARD_GRACE after the start. A watcher that used no CPU during a window, or one still unconfirmed when the grace runs out, still fails with confirmation-timeout. The first window always gets its full budget, even when the grace is shorter. Only that first window bounds how long a signal waits.

  • bin/fm-claude-stop-autoarm.sh now waits longer for the Claude Stop hook's handling successor to report. The limit is whichever is larger: GRACE or the arm's full first window (FM_ARM_CONFIRM_TIMEOUT + 1), plus 5 s of startup slack. The wait also stops as soon as the successor exits, and counts it as confirmed if it already printed a started/attached line.

  • New tests in tests/fm-watch-arm.test.sh and tests/fm-claude-stop-autoarm.test.sh cover:

    • a slow start that is still using CPU keeps getting windows
    • a stalled start, a start past the grace, and a grace shorter than the first window
    • a successor that confirms late, and a silent successor stopped at the grace

    docs/configuration.md and docs/watcher-continuity.md describe the new windows. They also note that the OpenCode and Pi ready timeouts are still fixed; waiting out the extended windows there is a possible follow-up. Known follow-up: the Claude Stop hook reads FM_GUARD_GRACE and FM_ARM_CONFIRM_TIMEOUT only from its own environment, while the arm also loads config/watch.env, so a larger value set only in watch.env can still let the arm keep confirming after the hook stops waiting; this predates this change and is left out of this PR.

🤖 Generated with Claude Code

Risk Assessment

⚠️ Medium: The arm's progress-based window extension is bounded, has behavior tests, and matches the intent. When the grace is shorter than the first window, the Claude successor wait now covers the arm's full first window, so a valid start inside it is not reported as unconfirmed.

Testing

In a marked lab home I drove the real arm and a real watcher from the base and target commits. With a 16s busy watcher start and the default 10s window, the base commit failed with confirmation-timeout and the target confirmed watcher: started. Under real load (40 CPU burners, load average 27-50) with the default window, both base and target confirmed (base after 4s, target after 7s), so plain load on this host did not reproduce the original failure. With the window cut to 1s, base failed and target confirmed in both runs. The guards held live: a stalled start fails at the first window, a start that never stops spinning is cut off at FM_GUARD_GRACE, a grace shorter than the window keeps the full first window and gives no extension, and a TERM during an extended window exits within 1s, stops the watcher and records reason=arm-interrupted. The Claude Stop-hook successor wait was checked only through its full behavior test file (all 66 tests pass), not with a live Claude primary, so that scenario is untested live. The lab home, base copy and driver files were removed afterwards.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A watcher start that needs longer than FM_ARM_CONFIRM_TIMEOUT but keeps using CPU is confirmed (watcher: started), not killed with confirmation-timeout ✅ pass live slow-start-base-vs-target.txt: base FAILED at 12s with ledger reason=confirmation-timeout; target printed watcher: started at 21s with no ledger row
Under real heavy load (load 40+ on 10 cores) a start slower than the window is confirmed instead of failing ✅ pass live real-load-short-window-base-vs-target.txt: base FAILED with confirmation-timeout in both runs, target started in both runs (FM_ARM_CONFIRM_TIMEOUT=1). real-load-base-vs-target.txt: with the default 10…
Guard: a watcher start that stops using CPU still fails at the first window ✅ pass live guards.txt: FAILED after about 4s, ledger reason=confirmation-timeout
Guard: a start that spins forever is cut off at FM_GUARD_GRACE ✅ pass live guards.txt: GRACE=8 gives FAILED at 8s (ledger 7s), reason=confirmation-timeout
Guard: a grace shorter than the window keeps the full first window and gives no extension ✅ pass live guards.txt: TIMEOUT=5 GRACE=1 gives FAILED after about 6s (not 1s, and not at the 12s start completion)
Guard: an arm sent TERM during an extended window exits at once and stops its watcher ✅ pass live term-during-extension.txt: exit 143 within 1s, reason=arm-interrupted, watcher pid gone
Claude Stop-hook handling successor: a late-reporting successor is confirmed, a successor reporting inside a first window longer than the grace is confirmed, and a silent successor is bounded by the g… ⏸️ untested no The prior payload did not establish a live result: only the behavior test file ran (fm-claude-stop-autoarm.test.log, 66 ok, 0 not ok). A live proof needs a real Claude primary in a lab whose watcher d…
Evidence: Slow watcher start, base vs target (default 10s window)

Source: Slow watcher start, base vs target (default 10s window)

=== BASE 7da4c362: watcher start busy 16s, default FM_ARM_CONFIRM_TIMEOUT (10)  bin=/var/folders/pb/f9rgj1l908j46jbl7bp1bc_m0000gp/T//fmbase.SOu2KA/bin  env:  slow-start snippet: end=$((SECONDS + 16)); while [ "$SECONDS" -lt "$end" ]; do :; done
load: 8.85 8.73 9.15
status line after 12s:
watcher: FAILED - no live watcher with a fresh beacon
ledger:
  arm_pid=85768 watcher_pid=85988 origin=started started_at=1793153369 ended_at=1793153380 exit_code=143 signal=TERM reason=confirmation-timeout beacon_age=24 lock_before=pid:none|identity:none lock_after=pid:none|identity:none successor=none
arm exit status: 1
no leftover processes

=== TARGET ab79e99f: watcher start busy 16s, default FM_ARM_CONFIRM_TIMEOUT (10)  bin=~/.no-mistakes/worktrees/5447c8da5417/01M603TTBXMK3K9M7ZHT4SV10Q/bin  env:  slow-start snippet: end=$((SECONDS + 16)); while [ "$SECONDS" -lt "$end" ]; do :; done
load: 8.99 8.78 9.16
status line after 21s:
watcher: started pid=90510 (beacon fresh)
ledger:
arm exit status: 143
no leftover processes
Evidence: Real CPU load (40 burners), default window, base vs target

Source: Real CPU load (40 burners), default window, base vs target

40 CPU burners (yes) running on 10 cores
=== BASE 7da4c362 under real load, default timeout  bin=/var/folders/pb/f9rgj1l908j46jbl7bp1bc_m0000gp/T//fmbase.SOu2KA/bin  env:  
load: 26.89 13.29 10.78
status line after 4s:
watcher: started pid=6259 (beacon fresh)
ledger:
arm exit status: 143
no leftover processes

=== TARGET ab79e99f under real load, default timeout  bin=~/.no-mistakes/worktrees/5447c8da5417/01M603TTBXMK3K9M7ZHT4SV10Q/bin  env:  
load: 32.03 14.83 11.36
status line after 7s:
watcher: started pid=9564 (beacon fresh)
ledger:
arm exit status: 143
no leftover processes
Evidence: Real CPU load with 1s window, base vs target x2

Source: Real CPU load with 1s window, base vs target x2

40 CPU burners (yes) running on 10 cores; window scaled down to FM_ARM_CONFIRM_TIMEOUT=1 to mirror a start slower than the window
=== BASE 7da4c362 under real load run 1  bin=/var/folders/pb/f9rgj1l908j46jbl7bp1bc_m0000gp/T//fmbase.SOu2KA/bin  env: FM_ARM_CONFIRM_TIMEOUT=1 
load: 42.47 19.58 13.25
status line after 5s:
watcher: FAILED - no live watcher with a fresh beacon
ledger:
  arm_pid=22212 watcher_pid=22287 origin=started started_at=1793153491 ended_at=1793153494 exit_code=143 signal=TERM reason=confirmation-timeout beacon_age=40 lock_before=pid:none|identity:none lock_after=pid:22287|identity:none successor=none
arm exit status: 1
no leftover processes

=== TARGET ab79e99f under real load run 1  bin=~/.no-mistakes/worktrees/5447c8da5417/01M603TTBXMK3K9M7ZHT4SV10Q/bin  env: FM_ARM_CONFIRM_TIMEOUT=1 
load: 46.16 21.12 13.87
status line after 5s:
watcher: started pid=24379 (beacon fresh)
ledger:
arm exit status: 143
no leftover processes

=== BASE 7da4c362 under real load run 2  bin=/var/folders/pb/f9rgj1l908j46jbl7bp1bc_m0000gp/T//fmbase.SOu2KA/bin  env: FM_ARM_CONFIRM_TIMEOUT=1 
load: 48.23 21.96 14.21
status line after 4s:
watcher: FAILED - no live watcher with a fresh beacon
ledger:
  arm_pid=26808 watcher_pid=26826 origin=started started_at=1793153505 ended_at=1793153507 exit_code=143 signal=TERM reason=confirmation-timeout beacon_age=5 lock_before=pid:none|identity:none lock_after=pid:26826|identity:none successor=none
arm exit status: 1
no leftover processes

=== TARGET ab79e99f under real load run 2  bin=~/.no-mistakes/worktrees/5447c8da5417/01M603TTBXMK3K9M7ZHT4SV10Q/bin  env: FM_ARM_CONFIRM_TIMEOUT=1 
load: 49.97 22.76 14.54
status line after 6s:
watcher: started pid=28783 (beacon fresh)
ledger:
arm exit status: 143
no leftover processes
Evidence: Guard scenarios: stalled, grace cap, short grace

Source: Guard scenarios: stalled, grace cap, short grace

=== TARGET guard: stalled start (no CPU), FM_ARM_CONFIRM_TIMEOUT=3 - expect FAILED at ~4s  bin=~/.no-mistakes/worktrees/5447c8da5417/01M603TTBXMK3K9M7ZHT4SV10Q/bin  env: FM_ARM_CONFIRM_TIMEOUT=3 slow-start snippet: exec sleep 60
load: 50.22 24.67 15.42
status line after 6s:
watcher: FAILED - no live watcher with a fresh beacon
ledger:
  arm_pid=34849 watcher_pid=34898 origin=started started_at=1793153530 ended_at=1793153534 exit_code=143 signal=TERM reason=confirmation-timeout beacon_age=18 lock_before=pid:none|identity:none lock_after=pid:none|identity:none successor=none
arm exit status: 1
no leftover processes

=== TARGET guard: spins forever, FM_ARM_CONFIRM_TIMEOUT=2 FM_GUARD_GRACE=8 - expect FAILED at ~8s  bin=~/.no-mistakes/worktrees/5447c8da5417/01M603TTBXMK3K9M7ZHT4SV10Q/bin  env: FM_ARM_CONFIRM_TIMEOUT=2 FM_GUARD_GRACE=8 slow-start snippet: while :; do :; done
load: 48.36 24.71 15.49
status line after 8s:
watcher: FAILED - no live watcher with a fresh beacon
ledger:
  arm_pid=37142 watcher_pid=37177 origin=started started_at=1793153536 ended_at=1793153543 exit_code=143 signal=TERM reason=confirmation-timeout beacon_age=27 lock_before=pid:none|identity:none lock_after=pid:none|identity:none successor=none
arm exit status: 1
no leftover processes

=== TARGET guard: grace shorter than window, spins 12s, FM_ARM_CONFIRM_TIMEOUT=5 FM_GUARD_GRACE=1 - expect FAILED at ~6s, not ~1s  bin=~/.no-mistakes/worktrees/5447c8da5417/01M603TTBXMK3K9M7ZHT4SV10Q/bin  env: FM_ARM_CONFIRM_TIMEOUT=5 FM_GUARD_GRACE=1 slow-start snippet: end=$((SECONDS + 12)); while [ "$SECONDS" -lt "$end" ]; do :; done
load: 43.79 24.50 15.52
status line after 7s:
watcher: FAILED - no live watcher with a fresh beacon
ledger:
  arm_pid=39304 watcher_pid=39343 origin=started started_at=1793153544 ended_at=1793153550 exit_code=143 signal=TERM reason=confirmation-timeout beacon_age=34 lock_before=pid:none|identity:none lock_after=pid:none|identity:none successor=none
arm exit status: 1
no leftover processes
Evidence: TERM during extended window

Source: TERM during extended window

=== TARGET: TERM the arm during an extended window (FM_ARM_CONFIRM_TIMEOUT=3, default grace, watcher start spins)
arm alive at 7s (past first 4s window): yes
arm exited 143 within 1s of TERM
arm_pid=39304 watcher_pid=39343 origin=started started_at=1793153544 ended_at=1793153550 exit_code=143 signal=TERM reason=confirmation-timeout beacon_age=34 lock_before=pid:none|identity:none lock_after=pid:none|identity:none successor=none
arm_pid=44149 watcher_pid=44340 origin=started started_at=1793153559 ended_at=1793153566 exit_code=143 signal=TERM reason=arm-interrupted beacon_age=50 lock_before=pid:none|identity:none lock_after=pid:none|identity:none successor=none
watcher pid 44340 alive after: no
Evidence: Claude stop-hook autoarm test run

Source: Claude stop-hook autoarm test run

ok - auto-arm: inert in a linked child worktree even when in-flight
ok - auto-arm: inert with no session lock
ok - auto-arm: a demonstrably dead recorded session owner is reclaimed through fm-lock.sh before arming
ok - auto-arm: inert without arm, rewake, or lock replacement when another live harness owns the home
ok - auto-arm: inert while AFK owns supervision
ok - auto-arm: stale-owner recovery leaves the AFK and supervision-need gates unchanged
ok - auto-arm: resolves the outermost pid of a nested contiguous claude ancestry (bg-spare chain)
ok - auto-arm: inert with nothing in flight and no X-mode need
ok - auto-arm: actionable close translates to exactly one exit-2 rewake with reason
ok - auto-arm: actionable close survives a healthy successor without duplicate delivery
ok - auto-arm: an attached cycle's end starts a handling successor named after the closed arm before the rewake
ok - auto-arm: an unconfirmed handling successor is reported in the rewake instead of blocking it
ok - auto-arm: a handling successor confirming past FM_ARM_CONFIRM_TIMEOUT is still confirmed
ok - auto-arm: a handling successor confirming inside a first window longer than the grace is confirmed
ok - auto-arm: a silent handling successor is bounded by the grace cap and reported
ok - auto-arm: bounded failure verification emits one automatic-mechanism alarm
ok - auto-arm: consecutive failures keep Stop-owned retry without repeating notice
ok - auto-arm: marker-write refusal defers delivery until one successor commits the notice
ok - auto-arm: unverified clean close exhausts retries and fails closed
ok - auto-arm: post-alarm actionable outcomes cannot continue or reset failure state
ok - auto-arm: benign cycle end with a live watcher and fresh beacon stays silent across the next cycle
ok - auto-arm: budget contention preserves the episode and forces a reset retry
ok - auto-arm: owner-mutex contention preserves successor episode state
ok - auto-arm: X-mode poll need arms the cycle even with no tasks in flight
ok - auto-arm: a registered custom check arms the cycle even with no tasks in flight
ok - auto-arm: concurrent firings admit one owner and one rewake translation
ok - auto-arm: TERM mid-arm commits a durable failure and exits 2 for rewake
ok - auto-arm: an abandoned owner claim is reclaimed so a lapsed cycle re-arms
ok - auto-arm: an abandoned-claim reclaim reaps a dead steal mutex without nesting
ok - auto-arm: a legacy owner still arming is never reclaimed while its watcher keeps beating
ok - auto-arm: a fresh legacy arming claim is never reclaimed while its startup window is still open
ok - auto-arm: a live claim the ledger does not name is never reclaimed
ok - auto-arm: a claim whose pid was reused is reclaimed even while its ledger entry still reads arming
ok - auto-arm: a reused-pid claim is reclaimed even with no ledger entry to prove it
ok - auto-arm: an identity-matched owner still arming is never reclaimed
ok - auto-arm: the guard's terminal-check claim is never reclaimed
ok - auto-arm: a stuck live legacy owner is retired via TERM and its lock reclaimed
ok - auto-arm: a SIGSTOPped legacy owner is reclaimed with TERM pending and dies on continue
ok - auto-arm: a live open generation claim defers concurrent firings with no lock held
ok - auto-arm: a hung generation owner with no watcher beat is superseded so re-arming self-heals
ok - auto-arm: a dead session's orphaned open claim is superseded so the replacement session delivers the wake
ok - auto-arm: a resumed session with the dead session's id still supersedes its orphaned claim
ok - auto-arm: a claim is open only under a numeric session lock its owner descends from
ok - auto-arm: a recycled-bridge claim stays open on its recorded session id and lock-holder identity, and nothing else does
ok - auto-arm: an identityless arming ledger never defers the gate (reused-pid loophole closed)
ok - auto-arm: a superseded owner never re-invokes the arm and leaves its successor's claim untouched
ok - auto-arm: a superseded owner goes silent - one supersession episode, one translation, no held mutex
ok - auto-arm: need vanishing mid-cycle closes without a rewake
ok - auto-arm: mid-cycle AFK hands triage to the daemon with no rewake
ok - auto-arm: active in a marked secondmate home
ok - auto-arm: a long FM_POLL with FM_GUARD_GRACE unset reaches fm-watch-arm.sh with the derived grace
ok - auto-arm: config/supervision-host-off keeps the hook on the arm exactly as before
ok - auto-arm: a Claude home without config/supervision-host runs the host by default
ok - auto-arm: an opted-in home runs the host bound to its generation, and a host line rewakes like a wake
ok - auto-arm: a wake the host hands back under the away record says it is automatic supervision, not a return
ok - auto-arm: a wake the host hands back beside a quiet record carries no away note
ok - auto-arm: without the host the rewake banner is unchanged, eight wake lines at most
ok - auto-arm: a host handback delivers every host line, while its wake lines keep their cap
ok - auto-arm: a host that stood down closes silently without a retry
ok - auto-arm: a host rewake refused on an acknowledged marker opens no failure episode
ok - auto-arm: a lost host hand-back notifies once per failure episode
ok - auto-arm: a lost host hand-back on an announced marker notifies once per failure episode
ok - auto-arm: a host that died without a close is retried, then reported as a failure
ok - auto-arm: --help, -h, and an unknown argument arm nothing; the Stop path still arms
ok - fm-lock: shared session-lock lib preserves the status path
ok - auto-arm: stands down only on a pi-code-delivered transcript_path (/.pi/)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⏭️ **Rebase** - skipped

Step was skipped.

⚠️ **Review** - 2 warnings
  • ⚠️ bin/fm-claude-stop-autoarm.sh:371 - start_handling_successor now bounds its wait by GRACE+5 alone, but fm-watch-arm.sh (after ba7245d) never shortens the first confirmation window: it is always FM_ARM_CONFIRM_TIMEOUT+1 even when FM_GUARD_GRACE is smaller, and docs/configuration.md now documents that configuration as supported. Example: FM_GUARD_GRACE=10 and FM_ARM_CONFIRM_TIMEOUT=30 (the value the intent's home put in config/watch.env). The hook gives up at about 15s and adds the 'did not confirm a live watcher' banner, even though the successor arm is still validly inside its first 31s window and may print 'watcher: started' later. Before this change the hook waited FM_ARM_CONFIRM_TIMEOUT+2, so this setup worked. Fix: set the budget to max(GRACE, FM_ARM_CONFIRM_TIMEOUT+1) plus slack, matching the arm's rule that the first window is the full budget and GRACE caps only the extensions. The comment at bin/fm-claude-stop-autoarm.sh:363-366 and docs/watcher-continuity.md:175 describe the bound as GRACE-only and need the same correction.

🔧 Fix applied.
2 warnings still open:

  • ⚠️ bin/fm-claude-stop-autoarm.sh:371 - start_handling_successor now bounds its wait by GRACE+5 alone, but fm-watch-arm.sh (after ba7245d) never shortens the first confirmation window: it is always FM_ARM_CONFIRM_TIMEOUT+1 even when FM_GUARD_GRACE is smaller, and docs/configuration.md now documents that configuration as supported. Example: FM_GUARD_GRACE=10 and FM_ARM_CONFIRM_TIMEOUT=30 (the value the intent's home put in config/watch.env). The hook gives up at about 15s and adds the 'did not confirm a live watcher' banner, even though the successor arm is still validly inside its first 31s window and may print 'watcher: started' later. Before this change the hook waited FM_ARM_CONFIRM_TIMEOUT+2, so this setup worked. Fix: set the budget to max(GRACE, FM_ARM_CONFIRM_TIMEOUT+1) plus slack, matching the arm's rule that the first window is the full budget and GRACE caps only the extensions. The comment at bin/fm-claude-stop-autoarm.sh:363-366 and docs/watcher-continuity.md:175 describe the bound as GRACE-only and need the same correction.
  • ⚠️ bin/fm-claude-stop-autoarm.sh:371 - start_handling_successor works out its wait budget from GRACE (hook line 168) and FM_ARM_CONFIRM_TIMEOUT, but it reads both only from the hook's environment. The arm sources $CONFIG/watch.env (bin/fm-watch-arm.sh:141) after it receives FM_GUARD_GRACE="$GRACE", so a value in watch.env overrides what the hook passed. Concrete case: a home sets FM_GUARD_GRACE=900 in config/watch.env, which is the documented place for it (docs/configuration.md:3060). The hook's GRACE stays at the poll-derived 300, so it waits about 305s. The arm keeps granting windows to a progressing watcher until 900s, so the hook reports 'did not confirm a live watcher' while the arm is still validly confirming. The same applies to FM_ARM_CONFIRM_TIMEOUT above about 299 in watch.env. This mismatch predates this round: the base FM_ARM_CONFIRM_TIMEOUT+2 bound ignored watch.env too, and this round's max(GRACE, window+1) actually fixes the intent's own FM_ARM_CONFIRM_TIMEOUT=30-in-watch.env case. Sibling sites with the same config-source mismatch: bin/fm-claude-stop-autoarm.sh:168 (GRACE) and :373-376 (window). The smallest honest remedy is to have the hook resolve these values from watch.env the way the arm does. That would also change the hook's other GRACE-based decisions, so the remedy, not the defect, needs authorization.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A watcher start that needs longer than FM_ARM_CONFIRM_TIMEOUT but keeps using CPU is confirmed (watcher: started), not killed with confirmation-timeout ✅ pass live slow-start-base-vs-target.txt: base FAILED at 12s with ledger reason=confirmation-timeout; target printed watcher: started at 21s with no ledger row
Under real heavy load (load 40+ on 10 cores) a start slower than the window is confirmed instead of failing ✅ pass live real-load-short-window-base-vs-target.txt: base FAILED with confirmation-timeout in both runs, target started in both runs (FM_ARM_CONFIRM_TIMEOUT=1). real-load-base-vs-target.txt: with the default 10…
Guard: a watcher start that stops using CPU still fails at the first window ✅ pass live guards.txt: FAILED after about 4s, ledger reason=confirmation-timeout
Guard: a start that spins forever is cut off at FM_GUARD_GRACE ✅ pass live guards.txt: GRACE=8 gives FAILED at 8s (ledger 7s), reason=confirmation-timeout
Guard: a grace shorter than the window keeps the full first window and gives no extension ✅ pass live guards.txt: TIMEOUT=5 GRACE=1 gives FAILED after about 6s (not 1s, and not at the 12s start completion)
Guard: an arm sent TERM during an extended window exits at once and stops its watcher ✅ pass live term-during-extension.txt: exit 143 within 1s, reason=arm-interrupted, watcher pid gone
Claude Stop-hook handling successor: a late-reporting successor is confirmed, a successor reporting inside a first window longer than the grace is confirmed, and a silent successor is bounded by the g… ⏸️ untested no The prior payload did not establish a live result: only the behavior test file ran (fm-claude-stop-autoarm.test.log, 66 ok, 0 not ok). A live proof needs a real Claude primary in a lab whose watcher d…
  • bin/fm-lab-home.sh create $LAB, then the real bin/fm-watch-arm.sh with FM_HOME=$LAB from base 7da4c362 and from target ab79e99f: watcher start kept busy for 16s through BASH_ENV, default FM_ARM_CONFIRM_TIMEOUT=10
  • Real host load from 40 yes CPU burners on 10 cores (load average 27-50): base vs target arm with the default window, then with FM_ARM_CONFIRM_TIMEOUT=1, two runs each
  • Target arm, stalled start (exec sleep 60), FM_ARM_CONFIRM_TIMEOUT=3
  • Target arm, start that spins forever, FM_ARM_CONFIRM_TIMEOUT=2 FM_GUARD_GRACE=8
  • Target arm, grace shorter than the window: FM_ARM_CONFIRM_TIMEOUT=5 FM_GUARD_GRACE=1 with a 12s busy start
  • Target arm sent TERM at 7s, during an extended window (FM_ARM_CONFIRM_TIMEOUT=3); checked exit time, ledger reason and that the watcher pid was gone
  • bash tests/fm-claude-stop-autoarm.test.sh (66 ok, 0 not ok, including the 3 new successor tests)
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.

Max Koval added 3 commits October 27, 2026 22:43
On a heavily loaded host a freshly forked watcher can need longer than the
arm's confirmation window (10s, 30s on MSYS) to take its lock and beat. The
arm then TERMed that live child and reported confirmation-timeout, and every
retry repeated the same cold start, so the Claude Stop-hook auto-arm failed
after its bounded attempts and supervision stayed down.

A child still consuming CPU when a window ends is now granted another window,
up to FM_GUARD_GRACE after its start. A window with no CPU progress still
fails as before, and a signal still bounds its wait by the first window, so
owners that stop an unready arm see it exit promptly. FM_ARM_CONFIRM_TIMEOUT
keeps setting the window length.
@greptile-apps

greptile-apps Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with a non-blocking false-warning issue for timeouts set only in watch.env.

Reviews (3) · Last reviewed commit: "no-mistakes(review): Wait out the arm's ..." · Reviewed by Greptile

Comment thread bin/fm-watch-arm.sh Resolved
…ow stops at FM_GUARD_GRACE, even when the grace is shorter than FM_ARM_CONFIRM_TIMEOUT. The new test failed before the fix and passes after it. The full test file did not finish: it was stopped at the 10-minute limit during its last test, after 44 tests passed and none failed. Invariant: every confirmation window must end no later than confirm_limit (confirm_start + GRACE). The window end is set in two places. owned_child_progressed already capped the later windows; the first window was not capped. - **Fix (bin/fm-watch-arm.sh):** confirm_limit is now set first, and the first window_end is capped at it. The signal-path `deadline` used by handle_arm_signal is unchanged. - **Test (tests/fm-watch-arm.test.sh):** added test_arm_bounds_the_first_confirmation_window_by_a_shorter_grace next to the existing grace-bound test. It uses start_slowed_arm with a spinning watcher start, FM_ARM_CONFIRM_TIMEOUT=30 and FM_GUARD_GRACE=3. It checks that the arm ends within 20 seconds of wall-clock time, prints the typed confirmation failure, and writes a reason=confirmation-timeout ledger row. - **Why wall-clock:** a limit on the number of polls let the test pass even before the fix. The spinning child slows each poll, so 150 polls took about 32 seconds. Results: - **Before the fix:** the new test failed after 31 seconds. - **After the fix:** it passed in about 6 seconds. - **Full file:** stopped by the time limit during its last test, so it never completed. Before that, 44 tests passed and none failed. - **shellcheck:** reports only SC1091 info about sourced files and an SC2034 warning at tests/fm-watch-arm.test.sh:1824, in code this round did not touch. ci-1 and ci-2 were left unchanged as instructed: they are fork workflow runs waiting for maintainer approval
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: thank you @kova1max, the load-20 Mac failure is a real gap, and the base-vs-target transcript makes it easy to see.

I read the diff and approved the fork CI runs for b4a438c7. They're running now. The no-mistakes attestation matches this head.

Overlap: #7835 (@WTF-Am-ID, for #7791) attacks the same slow-start problem, including this initial arm confirmation. It uses a shared milestone-progress allowance (25s without progress, 100s cap) across the host successor, hand-back and arm. This PR uses CPU time as the progress signal, capped at FM_GUARD_GRACE. #7835 is further along (live evidence accepted, near-clean CI), but I'm not closing either. The captain will pick, and you don't need to do anything about it.

Notes for the decision: with defaults, a progressing start can now hold the confirmation up to FM_GUARD_GRACE (the arm defaults it to 300s; the Stop hook passes its derived grace) instead of about 10s. The Claude Stop hook's wait for the handling successor's status line also grows from about 32s to GRACE+5s. Any CPU use counts as progress, so a watcher spinning without beating is treated as slow rather than wedged until the grace runs out.

contract-class: new-default (it changes the unconfigured confirmation and Stop-hook wait). Once CI is green, this goes to the captain as a decision, not to you.

Max Koval added 2 commits October 28, 2026 03:51
Capping the first confirmation window at FM_GUARD_GRACE made a grace
shorter than FM_ARM_CONFIRM_TIMEOUT end the window early, so with a
grace of 0 the arm gave up before a watcher's immediate wake
(test_arm_propagates_immediate_wake_before_confirmation). The grace is
the beacon-freshness threshold; the first window stays the full
FM_ARM_CONFIRM_TIMEOUT budget, and the grace caps only the further
windows a still-progressing start earns.
Comment on lines +373 to +378
case "${OSTYPE:-}" in
msys*|mingw*|cygwin*) window=${FM_ARM_CONFIRM_TIMEOUT:-30} ;;
*) window=${FM_ARM_CONFIRM_TIMEOUT:-10} ;;
esac
case "$window" in ''|*[!0-9]*) window=30 ;; esac
[ "$(( window + 1 ))" -le "$budget" ] || budget=$(( window + 1 ))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Valid starts look failed

start_handling_successor reads FM_ARM_CONFIRM_TIMEOUT from the hook’s environment, but the real arm also reads config/watch.env. With FM_GUARD_GRACE=5 in the environment and FM_ARM_CONFIRM_TIMEOUT=30 only in watch.env, the hook waits about 16 seconds while the arm now allows its full 31-second first window. A successor that confirms after 20 seconds gets reported as unconfirmed even though its start is valid. The detached watcher can still finish starting, so this produces a false warning rather than stopping it.

Read the same effective timeout as the arm before calculating this wait, and cover file-based settings in the test.

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.

2 participants