Repository navigation
fix(bin): charge the watcher's pre-lock startup to its own arm budget - #35
Merged
Merged
Conversation
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.
…fter budget split
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
DELIBERATE DECISIONS I MADE - these are chosen, not oversights:
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.shnow bounds a freshly forked watcher in two phases instead of one. A newFM_ARM_STARTUP_TIMEOUT(default 120) covers fork to the child's own claim on this home's singleton lock, andFM_ARM_CONFIRM_TIMEOUTcovers 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-timeoutbeside the existingconfirmation-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, andchild_claimed_singletoncompares 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.shandbin/fm-pr-check-migrate.share 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.shgains three cases that inject the pre-lock duration through a fixturebin/of symlinks whosefm-pr-check-migrate.shstand-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 asstartup-timeout, and a claimed singleton that can never publish a fresh beacon still fails asconfirmation-timeout.docs/configuration.mdanddocs/watcher-continuity.mddocument 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.shaborts at its own guard,fail "a C compiler is required to build the fake Cursor process", because the validating machine has nocc,gcc,clangortcc. It exits in about 0.2s at that guard, before any production code under test runs, and the suite contains no reference tobin/fm-watch-arm.sh.tests/fm-watch-checkpoint.test.shfailed once while several suites ran concurrently, when its--seconds 1quiet-checkpoint case lost that one-second budget to machine load. It passed on both isolated re-runs and again in the fullwatcher-wake-lockfamily re-run, and it drivesbin/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.
bin/fm-test-run.sh tests/fm-watcher-lock.test.shsuite 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)
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
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)
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
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
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
Evidence: Malformed budget values on HEAD (both budgets)
Source: Malformed budget values on HEAD (both budgets)
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
Evidence: Colocated regression cases in the owning suite
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
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.
bin/fm-test-run.sh tests/fm-watcher-lock.test.shsuite 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 ofbin/fm-watch-arm.shat HEAD vsgit show 187ed1db:bin/fm-watch-arm.sh, throwaway home, pre-lock migration stand-in sleeping 12s, FM_ARM_CONFIRM_TIMEOUT left at its 10s defaultLive A/B of the realbin/fm-claude-stop-autoarm.shunder a fakeclaudeharness owning state/.lock in a throwaway primary checkout, same 12s pre-lock stage, HEAD vs base armLive drive with NOTHING injected: 40 polls registered throughbin/fm-check-register.sh, realbin/fm-pr-check-migrate.sh --checks-safe(timed at 12.91s cold / 3.55s warm), realbin/fm-watch.sh, default arm budgets, HEAD vs base armSampled 0.5s timeline of the real 40-poll scan withFM_ARM_CONFIRM_TIMEOUT=2, recording state/.watch.lock pid, beacon age, and arm stdout until confirmationAdversarial:FM_ARM_STARTUP_TIMEOUT=1 FM_ARM_CONFIRM_TIMEOUT=60with an 8s pre-lock stage (startup budget exhausted), andFM_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, withFM_ARM_STARTUP_TIMEOUT=1Adversarial: requirement 3 planted in every real-migration home - an unregisteredsneaky-task.check.shand a registeredtampered.check.shwhose bytes changed after registration, both marker-writing if ever executedAdversarial: malformedFM_ARM_STARTUP_TIMEOUT=abc,FM_ARM_STARTUP_TIMEOUT='3 4'on HEAD vs malformedFM_ARM_CONFIRM_TIMEOUT=abcon HEAD and on the base arm, plus the malformed value driven through the Stop auto-armgit 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.