Skip to content

fix(bin): charge the watcher's pre-lock startup to its own arm budget - #35

Merged
SchokoShake merged 3 commits into
mainfrom
fm/fm-watch-start-migration-slow-b8
Oct 5, 2026
Merged

SchokoShake merged 3 commits into
mainfrom
fm/fm-watch-start-migration-slow-b8

Conversation

@SchokoShake

@SchokoShake SchokoShake commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Intent

GOAL: make the firstmate watcher confirm its start within the arm's confirmation budget even when the pre-lock PR-check migration scan is slow, so the Claude Stop auto-arm stops failing with confirmation-timeout.

EVIDENCE THAT PROMPTED THIS (2026-10-05, the captain's home): the Stop-owned auto-arm reported "watcher: FAILED - no live watcher with a fresh beacon" after 2 bounded attempts. state/.watch-cycle-exits.log showed both attempts ending reason=confirmation-timeout, signal=TERM, about 11s after start, with the beacon never refreshed. bin/fm-pr-check-migrate.sh --checks-safe alone took real 9.1s with 35 state/*.check.sh files (10.6s on 2026-10-02). bin/fm-watch.sh runs that migration BEFORE taking the singleton lock and writing the first beacon, so the arm's default FM_ARM_CONFIRM_TIMEOUT of 10s was spent before the watcher could confirm, and it gets worse as more PR polls are registered. The interim workaround FM_ARM_CONFIRM_TIMEOUT=30 is not set in the session environment, and firstmate may not edit its own harness settings.

REQUIRED OUTCOMES, as stated by the requester:

  1. Reproduce the resulting confirmation-timeout deterministically in a test, WITHOUT depending on this machine's speed.
  2. Fix the root cause so a slow scan can NEVER consume the confirmation budget. Two candidates were offered: make the migration cheap when its scan marker is current and no check changed, and/or start the confirmation budget only once the watcher has finished its pre-lock work, or beat before it. Pick whichever keeps every existing safety property of the migration and the singleton, and explain the choice in the PR.
  3. Keep the non-executing migration's guarantees intact: an unsafe or changed check must still be quarantined before the watcher could ever execute it.
  4. Tests colocated as the repo guidelines require; the full relevant suites green.
  5. Do not touch the captain's live state/ directory; work only in the task worktree with throwaway homes.
  6. Later accepted requirement: mention the two environment-only suite failures in the PR body.

DELIBERATE DECISIONS I MADE - these are chosen, not oversights:

  • Chose the budget split, not the caching candidate. A new FM_ARM_STARTUP_TIMEOUT (default 120) bounds fork to singleton-lock claim, and FM_ARM_CONFIRM_TIMEOUT now bounds only lock claim to first fresh beacon, which is what it was written for. The split point is the child's own claim on this home's singleton lock, the one structural boundary the arm can observe.
  • Rejected caching the migration's scan verdict when the marker is current: it would trade the migration's byte-identity guarantee for speed. Rejected merely reducing the scan's per-poll constant (for example hoisting the per-stat uname fork): measured cost is linear in registered polls (7.1s at 35 polls, 14.1s at 70 on this machine), so a lower constant still grows past any fixed budget. Only the split makes the failure structurally impossible.
  • Rejected beating before the lock, even though the requester listed it: a second watcher that beats and then stands down with "already running" would make a live-but-wedged lock holder look healthy, defeating fm-watch.sh's FM_WATCHER_STALE_GRACE check and the docs/watcher-continuity.md invariant that only a supervising watcher process touches state/.last-watcher-beat.
  • bin/fm-watch.sh and bin/fm-pr-check-migrate.sh are deliberately left byte-for-byte untouched. That is how requirement 3 is met: the non-executing scan still runs before any check could be executed, and an unsafe or changed check is still quarantined first. Only the arm layer changed.
  • Added a distinct typed FAILED line and a distinct lifecycle-ledger reason "startup-timeout", so an unclaimed singleton is no longer misreported as an unpublished beacon. "confirmation-timeout" keeps its existing meaning.
  • Neither new boundary carries a health claim by design: the verdict stays fm_watcher_healthy (identity-matched live pid plus fresh beacon), so a claimed lock that never beats still fails loudly.
  • child_claimed_singleton compares the lock's pid file directly IN ADDITION to fm_watcher_lock_matches_pid, deliberately: a process identity covers start time and command line, which a peer watcher started in the same clock tick would share, so the identity match alone is not proof the claim is our child's.
  • Kept the Git Bash/MSYS 30s confirm default unchanged and said so in the comment, because this change re-measured no platform's post-lock publication cost. Deliberately did not validate FM_ARM_STARTUP_TIMEOUT's syntax, matching the existing unvalidated FM_ARM_CONFIRM_TIMEOUT rather than adding asymmetric validation.
  • The tests INJECT the pre-lock duration through a fixture bin/ of symlinks whose fm-pr-check-migrate.sh is a stand-in that sleeps. Registering real polls was rejected on purpose: the real scan's duration is this machine's fork speed times the poll count, which is exactly the machine-speed dependency requirement 1 forbids. The stand-in also logs that the watcher really ran it, so the case cannot pass vacuously.
  • Three cases drive the two budgets apart deliberately: a pre-lock stage that outlasts the confirmation budget still confirms and records no confirmation timeout (this one fails against the pre-fix arm with the exact reported message, verified); an exhausted startup budget fails loudly as startup-timeout; and a claimed singleton that can never publish a fresh beacon still fails as confirmation-timeout. The second case's stand-in duration bounds only the orphaned stand-in, not the arm wait, because the watcher installs no TERM trap until after that stage - recorded in a comment so it is not mistaken for the bound.
  • Reviewed every supported primary harness per the repo guidelines, not just Claude: Cursor awaits the arm's close and has no readiness budget; Grok and the unknown-harness protocol use a tracked background task with exit notification; Codex uses fm-watch-checkpoint.sh and never reaches the arm. Pi and OpenCode DO have arm-readiness timeouts (12s) and carry the same exposure one notch further out. I deliberately did NOT change those adapter defaults, because raising them to cover startup would hold the wake for the same stretch - that latency-versus-restoration trade belongs to those adapters. It is recorded as a named limit in docs/watcher-continuity.md instead. Runtime backends are unaffected: the boundary is a lock file and a beacon, which are backend-agnostic.

CONSTRAINTS FROM THE REPO'S OWN GUIDELINES (firstmate's shared tracked material): one full sentence per line in tracked Markdown; plain dash, never an em dash; no agent, model, or tool named as a commit co-author on any commit; bin scripts shellcheck-clean through bin/fm-lint.sh; tests colocated in tests/ extending the existing runner rather than a new one; tests must exercise behavior through an executable interface and must never assert implementation-source bytes; the one-owner rule, so each contract is stated in full exactly once and every other mention is a cross-reference; and AGENTS.md size discipline, so exact budgets and mechanics belong in the script header and docs/configuration.md rather than inline in AGENTS.md - which is why AGENTS.md is deliberately unchanged here.

What Changed

  • bin/fm-watch-arm.sh now bounds a freshly forked watcher in two phases instead of one. A new FM_ARM_STARTUP_TIMEOUT (default 120) covers fork to the child's own claim on this home's singleton lock, and FM_ARM_CONFIRM_TIMEOUT covers only lock claim to first fresh beacon, which is what that budget was written for. The split point is the one structural boundary the arm can observe, so the pre-lock PR check migration scan, whose cost grows with the registered poll count, can no longer consume the confirmation budget. Each phase carries its own typed FAILED line and its own lifecycle-ledger reason (startup-timeout beside the existing confirmation-timeout), so an unclaimed singleton is no longer reported as an unpublished beacon. The health verdict is unchanged: an identity-matched live pid plus a fresh beacon, and child_claimed_singleton compares the lock's pid file in addition to the identity match, because a peer watcher started in the same clock tick would share that identity.
  • bin/fm-watch.sh and bin/fm-pr-check-migrate.sh are byte-for-byte untouched, so the non-executing scan still runs before any check could be executed and an unsafe or changed check is still quarantined first. Caching the scan verdict while the marker is current was rejected because it trades the migration's byte-identity guarantee for speed, and merely lowering the scan's per-poll constant still grows past any fixed budget; beating before the lock was rejected because a second watcher that beats and then stands down would make a wedged lock holder look healthy. Only the budget split makes the reported failure structurally impossible.
  • tests/fm-watcher-lock.test.sh gains three cases that inject the pre-lock duration through a fixture bin/ of symlinks whose fm-pr-check-migrate.sh stand-in sleeps and logs that the watcher really ran it, so no case depends on this machine's speed: a pre-lock stage outlasting the confirmation budget still confirms and records no confirmation timeout, an exhausted startup budget fails loudly as startup-timeout, and a claimed singleton that can never publish a fresh beacon still fails as confirmation-timeout. docs/configuration.md and docs/watcher-continuity.md document both budgets, and the Pi and OpenCode arm-readiness bounds are recorded there as a named limit rather than raised: those adapter defaults (12s, 35s on Windows) stay unchanged, with only their now-stale rationale comments corrected.

Two suites in the relevant local set fail for environment-only reasons unrelated to this change, and CI is green on both.

  • tests/fm-cursor-primary.test.sh aborts at its own guard, fail "a C compiler is required to build the fake Cursor process", because the validating machine has no cc, gcc, clang or tcc. It exits in about 0.2s at that guard, before any production code under test runs, and the suite contains no reference to bin/fm-watch-arm.sh.
  • tests/fm-watch-checkpoint.test.sh failed once while several suites ran concurrently, when its --seconds 1 quiet-checkpoint case lost that one-second budget to machine load. It passed on both isolated re-runs and again in the full watcher-wake-lock family re-run, and it drives bin/fm-watch-checkpoint.sh, which never reaches the arm.

Across the 50 changed-selected suites those were the only non-green results; every other suite passed, including the three new cases.

Risk Assessment

✅ Low: The change is confined to the arm layer's verification loop, leaves bin/fm-watch.sh and bin/fm-pr-check-migrate.sh byte-identical so the migration's quarantine-before-execute guarantee is structurally untouched, only widens the fork-to-beacon window so no previously-passing sequence can now fail early, keeps the health verdict in fm_watcher_healthy, and is covered by a real pre/post-fix regression test plus two negative controls that pin each budget to its own phase.

Testing

I reproduced the reported confirmation-timeout live against the base-commit arm and showed the fix removes it, both at the arm CLI and at the real Claude Stop auto-arm surface, using throwaway homes and a 40-poll home where the genuine non-executing migration scan really costs ~13s. A sampled timeline proves a real pre-lock scan cannot consume even a 2s confirmation budget, and that the migration's own transient claim on the singleton does not start that budget early. Both new failure boundaries were driven adversarially and fail loudly with distinct typed lines and distinct ledger reasons, a legitimate peer attach is not preempted by the startup budget, and the migration's quarantine guarantee held in every real-migration drive with no unsafe check ever executed. The colocated suite plus the arm, Stop auto-arm and PR-check-security suites are green, but I count that as a unit-level signal rather than a live drive, so the host-speed-independence of the new regression cases is reported untested rather than passed. Evidence for the live scenarios is CLI transcripts and the sampled timeline rather than screenshots because this change has no rendered UI surface - its entire user-visible output is the arm's typed status lines, the Stop hook's stderr banner, and the state/.watch-cycle-exits.log ledger rows, all captured verbatim.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Claude Stop auto-arm on a home whose PR-check migration scan costs 12s: the pre-fix arm reports the captain's exact "auto-arm FAILED after 2 bounded attempts" notice, the fixed arm reports an ordinary… ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s2-claude-stop-autoarm-prefix-vs-fixed.txt - pre-fix: two reason=confirmation-timeout ledger rows plus .claude-autoarm-failure-notified; fi…
Real reported condition with nothing injected: 40 registered polls make the genuine non-executing migration cost ~13s, and the arm still confirms a fresh watcher at the untouched 10s default confirmat… ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s5-real-migration-40-registered-polls.txt - base arm: "watcher: FAILED - no live watcher with a fresh beacon" at 11s, reason=confirmation-t…
A slow pre-lock scan can never consume the confirmation budget: the real 40-poll scan runs ~20s while FM_ARM_CONFIRM_TIMEOUT is pinned to 2s, and the arm still confirms ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s6-real-scan-vs-2s-confirmation-budget-timeline.txt - 0.5s-sampled timeline stays silent through t+20s then prints "watcher: started pid=95…
The migration's own transient claim on state/.watch.lock does not start the confirmation clock before the arm's own child claims it ✅ pass live same timeline: state/.watch.lock/pid reads 952678 from t+1s to t+20s (the migration's watcher-exclusion claim) while the arm stays in the startup phase, and the 2s confirmation budget only begins when…
Exhausted startup budget fails loudly with its own typed line and is never misreported as an unpublished beacon ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s3-s4-adversarial-budget-boundaries.txt case A - "watcher: FAILED - watcher startup did not claim the supervision lock within 1s", exit 1,…
A claimed singleton that can never publish a fresh beacon still fails as confirmation-timeout, so neither new boundary carries a health claim ✅ pass live same file case B - FM_GUARD_GRACE=0: "watcher: FAILED - no live watcher with a fresh beacon", exit 1, ledger reason=confirmation-timeout with beacon_age=5 (the child did reach the post-lock phase), no…
Requirement 3: an unsafe unregistered poll and a registered check whose bytes changed after registration are still quarantined before the watcher could execute either, while legitimate polls stay arme… ✅ pass live s5 and s6 evidence files - quarantine holds sneaky-task.check./sneaky-task.diagnostic.ambiguous and tampered.check./tampered.diagnostic.ambiguous, no EXECUTED-* marker ever appeared after several re…
A legitimate attach to a healthy peer watcher is not preempted by the new startup budget ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s9-healthy-peer-attach-not-preempted.txt - with FM_ARM_STARTUP_TIMEOUT=1 and a 10s pre-lock stage for its own child, the arm printed "watch…
A malformed value for the new budget degrades exactly like a malformed value for the pre-existing budget, and still fails loudly at the Stop-hook surface rather than wedging silently ✅ pass live s7 and s8 evidence files - HEAD with FM_ARM_STARTUP_TIMEOUT=abc and base arm with FM_ARM_CONFIRM_TIMEOUT=abc both exit 1 with the same bash unbound-variable error and no typed line; through the Stop a…
The colocated regression cases reproduce the reported failure deterministically without depending on host speed and pass on the fixed arm ⏸️ untested no The prior payload recorded this only as a bin/fm-test-run.sh tests/fm-watcher-lock.test.sh suite run against a fixture bin stand-in, which is a unit-level harness rather than a drive of the live pro…
Evidence: Reported end-user surface: Claude Stop auto-arm, pre-fix FAILED after 2 bounded attempts vs fixed wake banner (12s pre-lock stage, default 10s confirm budget)

Source: Reported end-user surface: Claude Stop auto-arm, pre-fix FAILED after 2 bounded attempts vs fixed wake banner (12s pre-lock stage, default 10s confirm budget)

=== Reported end-user surface: the Claude Stop-owned auto-arm, pre-lock PR-check migration scan costing 12s, FM_ARM_CONFIRM_TIMEOUT left at its 10s default ===

\### Stop auto-arm case=hook-prefix prelock=12s arm=187ed1db20182345fc93c0baec421973aeabb17f exit=2 elapsed=23s
-- hook stdout (must stay silent) --
-- hook stderr (what the captain sees) --
firstmate watcher auto-arm FAILED - the Stop-owned automatic supervision mechanism is broken after 2 bounded attempts, and no live watcher with a fresh beacon was verified.
watcher: FAILED - no live watcher with a fresh beacon
Do not launch a manual background arm from this notice; investigate the automatic Stop hook and watcher startup before ending blind.
-- watcher lifecycle ledger --
arm_pid=178232	watcher_pid=178283	origin=started	started_at=1791194695	ended_at=1791194707	exit_code=143	signal=TERM	reason=confirmation-timeout	beacon_age=999999	lock_before=pid:none|identity:none	lock_after=pid:none|identity:none	successor=none
arm_pid=186312	watcher_pid=186367	origin=started	started_at=1791194707	ended_at=1791194718	exit_code=143	signal=TERM	reason=confirmation-timeout	beacon_age=999999	lock_before=pid:none|identity:none	lock_after=pid:none|identity:none	successor=none
-- failure markers --
.claude-autoarm-failure-notified

\### Stop auto-arm case=hook-fixed prelock=12s arm=HEAD(fixed) exit=2 elapsed=15s
-- hook stdout (must stay silent) --
-- hook stderr (what the captain sees) --
firstmate watcher wake - one supervision event needs a handling turn now.
signal: /tmp/fm-arm-live-lab.Y7YhW0/hook-fixed/state/smoke.status
Run bin/fm-wake-drain.sh first, handle the wake, then run its exact WAKE_ACK_REQUIRED --ack-through command. Until that post-handling acknowledgement, interruption leaves the wake durable for idempotent re-handling. This Stop hook owns watcher continuity: when the handling turn ends, the next needed cycle arms automatically - do NOT run bin/fm-watch-arm.sh after an ordinary wake.
-- watcher lifecycle ledger --
arm_pid=195966	watcher_pid=196017	origin=started	started_at=1791194719	ended_at=1791194733	exit_code=0	signal=none	reason=actionable-signal	beacon_age=2	lock_before=pid:196017|identity:linux-starttime=1274175 cmdline-hex=62617368002f746d702f666d2d61726d2d6c6976652d6c61622e5937596857302f686f6f6b2d66697865642f62696e2f666d2d77617463682e736800	lock_after=pid:none|identity:none	successor=none
-- failure markers --
(none)
Evidence: Real condition, nothing injected: real migration with 40 registered polls, default budgets, pre-fix arm fails at 11s vs fixed arm confirms at 21s; unsafe and tampered checks quarantined, never executed

Source: Real condition, nothing injected: real migration with 40 registered polls, default budgets, pre-fix arm fails at 11s vs fixed arm confirms at 21s; unsafe and tampered checks quarantined, never executed

=== The reported real condition, nothing injected: the real non-executing PR-check migration with 40 registered polls (12.9s cold on this host), real fm-watch.sh, default arm budgets ===

\### A) PRE-FIX arm (base 187ed1d)
arm=187ed1db20182345fc93c0baec421973aeabb17f  registered polls=40  budgets=DEFAULT (FM_ARM_CONFIRM_TIMEOUT and FM_ARM_STARTUP_TIMEOUT unset)
time to first arm line: 11s
arm stdout: watcher: FAILED - no live watcher with a fresh beacon
singleton lock claimed by: 325895
beacon age at stand-down: no beacon
ledger: arm_pid=325813	watcher_pid=325838	origin=started	started_at=1791194907	ended_at=1791194918	exit_code=143	signal=TERM	reason=confirmation-timeout	beacon_age=999999	lock_before=pid:none|identity:none	lock_after=pid:325895|identity:none	successor=none
quarantined by the non-executing migration: sneaky-task.check.SPgazF sneaky-task.diagnostic.ambiguous tampered.check.DSrFT4 tampered.diagnostic.ambiguous 
still armed in state/: poll-1.check.sh poll-10.check.sh poll-11.check.sh poll-12.check.sh poll-13.check.sh poll-14.check.sh poll-15.check.sh poll-16.check.sh poll-17.check.sh poll-18.check.sh poll-19.check.sh poll-2.check.sh poll-20.check.sh poll-21.check.sh poll-22.check.sh poll-23.check.sh poll-24.check.sh poll-25.check.sh poll-26.check.sh poll-27.check.sh poll-28.check.sh poll-29.check.sh poll-3.check.sh poll-30.check.sh poll-31.check.sh poll-32.check.sh poll-33.check.sh poll-34.check.sh poll-35.check.sh poll-36.check.sh poll-37.check.sh poll-38.check.sh poll-39.check.sh poll-4.check.sh poll-40.check.sh poll-5.check.sh poll-6.check.sh poll-7.check.sh poll-8.check.sh poll-9.check.sh 
markers proving an unsafe check RAN: 
markers proving an unsafe check RAN: NONE

\### B) FIXED arm (HEAD)
arm=HEAD(fixed)  registered polls=40  budgets=DEFAULT (FM_ARM_CONFIRM_TIMEOUT and FM_ARM_STARTUP_TIMEOUT unset)
time to first arm line: 21s
arm stdout: watcher: started pid=353206 (beacon fresh)
singleton lock claimed by: 353326
beacon age at stand-down: 7s
ledger: (empty)
quarantined by the non-executing migration: sneaky-task.check.RC0x6w sneaky-task.diagnostic.ambiguous tampered.check.QU2u2e tampered.diagnostic.ambiguous 
still armed in state/: poll-1.check.sh poll-10.check.sh poll-11.check.sh poll-12.check.sh poll-13.check.sh poll-14.check.sh poll-15.check.sh poll-16.check.sh poll-17.check.sh poll-18.check.sh poll-19.check.sh poll-2.check.sh poll-20.check.sh poll-21.check.sh poll-22.check.sh poll-23.check.sh poll-24.check.sh poll-25.check.sh poll-26.check.sh poll-27.check.sh poll-28.check.sh poll-29.check.sh poll-3.check.sh poll-30.check.sh poll-31.check.sh poll-32.check.sh poll-33.check.sh poll-34.check.sh poll-35.check.sh poll-36.check.sh poll-37.check.sh poll-38.check.sh poll-39.check.sh poll-4.check.sh poll-40.check.sh poll-5.check.sh poll-6.check.sh poll-7.check.sh poll-8.check.sh poll-9.check.sh 
markers proving an unsafe check RAN: 
markers proving an unsafe check RAN: NONE
Evidence: Sampled timeline: real 20s pre-lock scan against a 2s confirmation budget, showing the migration's own lock claim (pid 952678) not starting the confirmation clock before the child's claim (pid 952556)

Source: Sampled timeline: real 20s pre-lock scan against a 2s confirmation budget, showing the migration's own lock claim (pid 952678) not starting the confirmation clock before the child's claim (pid 952556)

Real non-executing PR-check migration (40 registered polls, ~13s cold on this host) with the
confirmation budget deliberately pinned to FM_ARM_CONFIRM_TIMEOUT=2 - far shorter than the scan.
FM_ARM_STARTUP_TIMEOUT left at its 120s default. Sampled every 0.5s:

  t+s  watch.lock pid  beacon                    arm stdout so far
    0  -               (none yet)                (silent)
    1  952678          (none yet)                (silent)
    1  952678          (none yet)                (silent)
    2  952678          (none yet)                (silent)
    2  952678          (none yet)                (silent)
    3  952678          (none yet)                (silent)
    3  952678          (none yet)                (silent)
    4  952678          (none yet)                (silent)
    4  952678          (none yet)                (silent)
    5  952678          (none yet)                (silent)
    5  952678          (none yet)                (silent)
    6  952678          (none yet)                (silent)
    6  952678          (none yet)                (silent)
    7  952678          (none yet)                (silent)
    7  952678          (none yet)                (silent)
    8  952678          (none yet)                (silent)
    8  952678          (none yet)                (silent)
    9  952678          (none yet)                (silent)
   10  952678          (none yet)                (silent)
   10  952678          (none yet)                (silent)
   11  952678          (none yet)                (silent)
   11  952678          (none yet)                (silent)
   12  952678          (none yet)                (silent)
   12  952678          (none yet)                (silent)
   13  952678          (none yet)                (silent)
   13  952678          (none yet)                (silent)
   14  952678          (none yet)                (silent)
   14  952678          (none yet)                (silent)
   15  952678          (none yet)                (silent)
   15  952678          (none yet)                (silent)
   16  952678          (none yet)                (silent)
   16  952678          (none yet)                (silent)
   17  952678          (none yet)                (silent)
   17  952678          (none yet)                (silent)
   18  952678          (none yet)                (silent)
   18  952678          (none yet)                (silent)
   19  952678          (none yet)                (silent)
   19  952678          (none yet)                (silent)
   20  952678          (none yet)                (silent)
   20  952556          age 0s                    watcher: started pid=952556 (beacon fresh)|

confirmed line: watcher: started pid=952556 (beacon fresh)
watch.lock pid at confirmation: 952556
lifecycle ledger: (empty - no timeout recorded)
quarantined before any check could run: sneaky-task.check.yih61M sneaky-task.diagnostic.ambiguous tampered.check.Kz0XUh tampered.diagnostic.ambiguous 
no unsafe check ever executed
Evidence: Adversarial budget boundaries: exhausted startup budget yields the typed startup-timeout line and ledger reason; a claimed singleton with no fresh beacon still yields confirmation-timeout

Source: Adversarial budget boundaries: exhausted startup budget yields the typed startup-timeout line and ledger reason; a claimed singleton with no fresh beacon still yields confirmation-timeout

=== Adversarial: the two budgets must stay separable and both must fail loudly ===

\### A) startup budget exhausted (FM_ARM_STARTUP_TIMEOUT=1) while the confirmation budget is generous (60s): the child never claims the singleton
env: FM_ARM_STARTUP_TIMEOUT=1 FM_ARM_CONFIRM_TIMEOUT=60 ; pre-lock stage=8s ; arm=HEAD
exit=1 elapsed=2s
stdout: watcher: FAILED - watcher startup did not claim the supervision lock within 1s
pre-lock stage really ran: 1791194785 ran --checks-safe
singleton lock claimed by child pid: never
ledger: arm_pid=213468	watcher_pid=213522	origin=started	started_at=1791194785	ended_at=1791194787	exit_code=143	signal=TERM	reason=startup-timeout	beacon_age=999999	lock_before=pid:none|identity:none	lock_after=pid:none|identity:none	successor=none

\### B) singleton claimed but no beacon can ever be fresh (FM_GUARD_GRACE=0), confirmation budget 3s, startup budget 60s
env: FM_GUARD_GRACE=0 FM_ARM_CONFIRM_TIMEOUT=3 FM_ARM_STARTUP_TIMEOUT=60 ; pre-lock stage=2s ; arm=HEAD
exit=1 elapsed=8s
stdout: watcher: FAILED - no live watcher with a fresh beacon
pre-lock stage really ran: 1791194787 ran --checks-safe
singleton lock claimed by child pid: never
ledger: arm_pid=215769	watcher_pid=215792	origin=started	started_at=1791194787	ended_at=1791194795	exit_code=1	signal=none	reason=confirmation-timeout	beacon_age=5	lock_before=pid:none|identity:none	lock_after=pid:none|identity:none	successor=none
Evidence: Arm CLI A/B with a 12s pre-lock stand-in at the default confirmation budget: pre-fix confirmation-timeout ledger row vs fixed confirmed watcher

Source: Arm CLI A/B with a 12s pre-lock stand-in at the default confirmation budget: pre-fix confirmation-timeout ledger row vs fixed confirmed watcher

=== S1 counterfactual: PRE-FIX arm (base 187ed1d), default FM_ARM_CONFIRM_TIMEOUT (10s), 12s pre-lock migration scan ===
\### case=prefix-default prelock=12s arm=187ed1db20182345fc93c0baec421973aeabb17f elapsed=11s rc=1
stdout: watcher: FAILED - no live watcher with a fresh beacon
migration ran: 1791194571 ran --checks-safe
singleton lock pid seen: none
ledger: arm_pid=110108	watcher_pid=110175	origin=started	started_at=1791194571	ended_at=1791194582	exit_code=143	signal=TERM	reason=confirmation-timeout	beacon_age=999999	lock_before=pid:none|identity:none	lock_after=pid:none|identity:none	successor=none

=== S1 fixed: HEAD arm, same home shape, same default budgets ===
\### case=fixed-default prelock=12s arm=HEAD elapsed=13s rc=running
stdout: watcher: started pid=122841 (beacon fresh)
migration ran: 1791194583 ran --checks-safe
singleton lock pid seen: 122841
ledger: (empty)
Evidence: Healthy peer attach is not preempted by FM_ARM_STARTUP_TIMEOUT=1

Source: Healthy peer attach is not preempted by FM_ARM_STARTUP_TIMEOUT=1

=== Adversarial: a legitimate attach must not be preempted by the new startup budget ===
A healthy peer watcher already holds this home's singleton; the arm's own forked child would
need 10s of pre-lock startup, and FM_ARM_STARTUP_TIMEOUT is pinned to 1s.

peer watcher live pid: 1073799 (beacon present: yes)
arm first line after 0s: watcher: attached pid=1073799 (beacon 0s)
arm stderr: 
ledger: (empty - no startup timeout recorded against a legitimate attach)
peer still live: yes
Evidence: Malformed budget values on HEAD (both budgets)

Source: Malformed budget values on HEAD (both budgets)

=== Malformed budget values: the new startup budget must degrade exactly like the pre-existing confirmation budget (the author's stated symmetry), never crash the arm ===

\### FM_ARM_CONFIRM_TIMEOUT=abc (pre-existing behavior) -> exit=124
stdout: watcher: started pid=1001351 (beacon fresh)
stderr: Terminated                 sleep "$POLL"

\### FM_ARM_STARTUP_TIMEOUT=abc (new budget) -> exit=1
stdout: 
stderr: /tmp/fm-arm-live-lab.Y7YhW0/garbage-FM-ARM-STARTUP-TIMEOUT-abc--new-budget--/bin/fm-watch-arm.sh: line 595: abc: unbound variable

\### FM_ARM_CONFIRM_TIMEOUT=3 4 (pre-existing behavior) -> exit=124
stdout: watcher: started pid=1026577 (beacon fresh)
stderr: Terminated                 sleep "$POLL"

\### FM_ARM_STARTUP_TIMEOUT=3 4 (new budget) -> exit=1
stdout: 
stderr: /tmp/fm-arm-live-lab.Y7YhW0/garbage-FM-ARM-STARTUP-TIMEOUT-3-4--new-budget--/bin/fm-watch-arm.sh: line 595: 3 4: arithmetic syntax error in expression (error token is "4")
/tmp/fm-arm-live-lab.Y7YhW0/garbage-FM-ARM-STARTUP-TIMEOUT-3-4--new-budget--/bin/fm-watch-arm.sh: line 637: startup_deadline: unbound variable
Evidence: Malformed-value regression check: base arm behaves identically for its own budget, and the Stop hook still fails loudly

Source: Malformed-value regression check: base arm behaves identically for its own budget, and the Stop hook still fails loudly

=== Is the malformed-value behavior of the NEW budget a regression, or the pre-existing budget's own behavior? ===

\### PRE-FIX arm (base 187ed1d) with FM_ARM_CONFIRM_TIMEOUT=abc -> exit=1
stdout: 
stderr: /tmp/fm-arm-live-lab.Y7YhW0/garbage-prefix-confirm/bin/fm-watch-arm.sh: line 557: abc: unbound variable

=== And does a malformed NEW budget still fail LOUDLY at the end-user surface (the Claude Stop auto-arm), never silently? ===

\### Stop auto-arm with FM_ARM_STARTUP_TIMEOUT=abc -> exit=2
hook stdout: 
hook stderr:
firstmate watcher auto-arm FAILED - the Stop-owned automatic supervision mechanism is broken after 2 bounded attempts, and no live watcher with a fresh beacon was verified.
Do not launch a manual background arm from this notice; investigate the automatic Stop hook and watcher startup before ending blind.
Evidence: Colocated regression cases in the owning suite
ok - pre-lock startup work is charged to the startup budget, not the confirmation budget
ok - an exhausted startup budget fails loudly and is classified apart from a confirmation timeout
ok - the confirmation budget still bounds a claimed singleton that never publishes a fresh beacon
FM_TEST_END tests/fm-watcher-lock.test.sh exit=0 duration_ms=87858 gate_skip=false

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-watch-arm.sh:113 - bin/fm-watch-arm.sh:113 - the Git Bash/MSYS platform allowance stayed on the confirmation budget while the cost that originally justified it moved to the new startup budget. The pre-change comment justified MSYS's 30s confirm default specifically because "Git Bash/MSYS pays a much higher fork cost while the watcher completes its required pre-lock migration". That pre-lock migration cost is now charged to STARTUP_TIMEOUT, which has no OSTYPE case and is a flat 120 on every platform, while the 30s MSYS case is kept at bin/fm-watch-arm.sh:103 and re-justified at lines 99-101 as covering post-lock publication - a cost those same lines state was not re-measured on any platform. This is not a regression: an MSYS home's fork-to-beacon window grows from 30s to roughly 150s, so every sequence that confirmed before still confirms. The note is only that the platform allowance now sits on the phase whose cost is poll-count-independent and is absent from the phase whose cost is fork-bound and therefore most MSYS-sensitive. No action is required here: the remedy would be an OSTYPE case for STARTUP_TIMEOUT or dropping the MSYS confirm special-case, both default-behavior changes that need measurement on that platform and exceed this change's stated scope, and the intent already records the confirm-side decision as deliberate.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Claude Stop auto-arm on a home whose PR-check migration scan costs 12s: the pre-fix arm reports the captain's exact "auto-arm FAILED after 2 bounded attempts" notice, the fixed arm reports an ordinary… ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s2-claude-stop-autoarm-prefix-vs-fixed.txt - pre-fix: two reason=confirmation-timeout ledger rows plus .claude-autoarm-failure-notified; fi…
Real reported condition with nothing injected: 40 registered polls make the genuine non-executing migration cost ~13s, and the arm still confirms a fresh watcher at the untouched 10s default confirmat… ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s5-real-migration-40-registered-polls.txt - base arm: "watcher: FAILED - no live watcher with a fresh beacon" at 11s, reason=confirmation-t…
A slow pre-lock scan can never consume the confirmation budget: the real 40-poll scan runs ~20s while FM_ARM_CONFIRM_TIMEOUT is pinned to 2s, and the arm still confirms ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s6-real-scan-vs-2s-confirmation-budget-timeline.txt - 0.5s-sampled timeline stays silent through t+20s then prints "watcher: started pid=95…
The migration's own transient claim on state/.watch.lock does not start the confirmation clock before the arm's own child claims it ✅ pass live same timeline: state/.watch.lock/pid reads 952678 from t+1s to t+20s (the migration's watcher-exclusion claim) while the arm stays in the startup phase, and the 2s confirmation budget only begins when…
Exhausted startup budget fails loudly with its own typed line and is never misreported as an unpublished beacon ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s3-s4-adversarial-budget-boundaries.txt case A - "watcher: FAILED - watcher startup did not claim the supervision lock within 1s", exit 1,…
A claimed singleton that can never publish a fresh beacon still fails as confirmation-timeout, so neither new boundary carries a health claim ✅ pass live same file case B - FM_GUARD_GRACE=0: "watcher: FAILED - no live watcher with a fresh beacon", exit 1, ledger reason=confirmation-timeout with beacon_age=5 (the child did reach the post-lock phase), no…
Requirement 3: an unsafe unregistered poll and a registered check whose bytes changed after registration are still quarantined before the watcher could execute either, while legitimate polls stay arme… ✅ pass live s5 and s6 evidence files - quarantine holds sneaky-task.check./sneaky-task.diagnostic.ambiguous and tampered.check./tampered.diagnostic.ambiguous, no EXECUTED-* marker ever appeared after several re…
A legitimate attach to a healthy peer watcher is not preempted by the new startup budget ✅ pass live ~/.no-mistakes/evidence/01M45QD9189EY8VCH8RV2GVRSK/s9-healthy-peer-attach-not-preempted.txt - with FM_ARM_STARTUP_TIMEOUT=1 and a 10s pre-lock stage for its own child, the arm printed "watch…
A malformed value for the new budget degrades exactly like a malformed value for the pre-existing budget, and still fails loudly at the Stop-hook surface rather than wedging silently ✅ pass live s7 and s8 evidence files - HEAD with FM_ARM_STARTUP_TIMEOUT=abc and base arm with FM_ARM_CONFIRM_TIMEOUT=abc both exit 1 with the same bash unbound-variable error and no typed line; through the Stop a…
The colocated regression cases reproduce the reported failure deterministically without depending on host speed and pass on the fixed arm ⏸️ untested no The prior payload recorded this only as a bin/fm-test-run.sh tests/fm-watcher-lock.test.sh suite run against a fixture bin stand-in, which is a unit-level harness rather than a drive of the live pro…
  • bin/fm-test-run.sh tests/fm-watcher-lock.test.sh - owning suite including the three new budget cases (exit=0, 35 cases)
  • bin/fm-test-run.sh tests/fm-watch-arm.test.sh tests/fm-claude-stop-autoarm.test.sh tests/fm-pr-check-security.test.sh - arm lifecycle, Stop auto-arm, and non-executing migration safety (exit=0)
  • Live A/B of bin/fm-watch-arm.sh at HEAD vs git show 187ed1db:bin/fm-watch-arm.sh, throwaway home, pre-lock migration stand-in sleeping 12s, FM_ARM_CONFIRM_TIMEOUT left at its 10s default
  • Live A/B of the real bin/fm-claude-stop-autoarm.sh under a fake claude harness owning state/.lock in a throwaway primary checkout, same 12s pre-lock stage, HEAD vs base arm
  • Live drive with NOTHING injected: 40 polls registered through bin/fm-check-register.sh, real bin/fm-pr-check-migrate.sh --checks-safe (timed at 12.91s cold / 3.55s warm), real bin/fm-watch.sh, default arm budgets, HEAD vs base arm
  • Sampled 0.5s timeline of the real 40-poll scan with FM_ARM_CONFIRM_TIMEOUT=2, recording state/.watch.lock pid, beacon age, and arm stdout until confirmation
  • Adversarial: FM_ARM_STARTUP_TIMEOUT=1 FM_ARM_CONFIRM_TIMEOUT=60 with an 8s pre-lock stage (startup budget exhausted), and FM_GUARD_GRACE=0 FM_ARM_CONFIRM_TIMEOUT=3 FM_ARM_STARTUP_TIMEOUT=60 (claimed lock, no fresh beacon possible)
  • Adversarial: healthy peer watcher already holding the singleton while the arm's child needs 10s of pre-lock work, with FM_ARM_STARTUP_TIMEOUT=1
  • Adversarial: requirement 3 planted in every real-migration home - an unregistered sneaky-task.check.sh and a registered tampered.check.sh whose bytes changed after registration, both marker-writing if ever executed
  • Adversarial: malformed FM_ARM_STARTUP_TIMEOUT=abc, FM_ARM_STARTUP_TIMEOUT='3 4' on HEAD vs malformed FM_ARM_CONFIRM_TIMEOUT=abc on HEAD and on the base arm, plus the malformed value driven through the Stop auto-arm
  • git diff --stat 187ed1db..HEAD -- bin/fm-watch.sh bin/fm-pr-check-migrate.sh - confirmed byte-identical, so the migration still runs before any lock or check enumeration
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

The watcher must finish required pre-lock startup work before it can claim
this home's singleton or write its first beacon, and the dominant part of that
work is the non-executing PR check migration, whose scan validates every
registered poll's artifacts. That scan's cost grows with the number of
registered polls: measured here, 7.1s at 35 polls and 14.1s at 70, even on the
marker-current path every re-arm takes. The arm measured its single
confirmation budget from the fork, so that budget was spent on startup the
child had not finished yet, and the Claude Stop auto-arm reported
"no live watcher with a fresh beacon" with both bounded attempts logged as
reason=confirmation-timeout.

Split the wait at the one structural boundary the arm can observe: the child's
own claim on the singleton lock. FM_ARM_STARTUP_TIMEOUT now bounds fork to
lock claim, and the pre-lock scan can only ever consume that budget;
FM_ARM_CONFIRM_TIMEOUT then bounds lock claim to first fresh beacon, which is
what it was written for. Each phase carries its own typed failure line and its
own lifecycle-ledger reason, so an unclaimed singleton is no longer reported as
an unpublished beacon.

Nothing about the migration or the singleton changes: the watcher is untouched,
so the scan still runs before any check could be executed and an unsafe or
changed check is still quarantined first. Neither new boundary carries a health
claim either - the verdict remains an identity-matched live watcher with a
fresh beacon, so a claimed lock that never beats still fails loudly.

The alternative of caching the scan's verdict was rejected: it would trade the
migration's byte-identity guarantee for speed, and a lower constant would still
grow with the poll count.
…uration

The case only needs its injected pre-lock stage to outlast a 1s startup budget,
so 30s bought nothing and left a 30s orphaned stand-in behind every run. The
arm's cleanup kill ends the child immediately because the watcher installs no
TERM trap until after that stage, which is what actually bounds how long the
arm is waited on; record that so the duration is not mistaken for the bound.
@SchokoShake
SchokoShake merged commit b6cb7d4 into main Oct 5, 2026
13 checks passed
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