feat: add live-head merge gates and away task grants - #4199
Merged
Merged
Conversation
A GitHub merge now re-reads the pull request and passes --match-head-commit, so a red or moved head cannot land the way GitLab already refused. While an away record exists, only yolo or a named grant may merge, so hold-for-return cannot ship an ungated PR. Co-authored-by: Cursor <cursoragent@cursor.com>
…b merge fixtures for live-head verification/direct gh merges and removing a process-event runner cleanup race. Verified fm-pr-check-security, fm-captain-hold-lifecycle, and fm-watch-triage pass locally; shell syntax and git diff checks also pass
kunchenguid
force-pushed
the
fm/fm-afk-merge-safety-leanc-plan-s1
branch
from
September 11, 2026 15:24
0d6ad0b to
ffed0f8
Compare
This was referenced Sep 12, 2026
msylergy
added a commit
to sylergydigital/firstmate
that referenced
this pull request
Sep 14, 2026
…y fork (#1) * fix(bin): suppress false Claude long-turn supervision alarms (#4119) * fix(bin): stop false watcher-down alarms on long Claude turns A healthy Stop auto-arm rewake or open claim already explains a mid-turn beacon that has aged past grace, because turn-end will re-arm. Keep the supervision-off banner for a missing, failed, or exhausted generation. * no-mistakes(review): Bind Claude rewakes to active recovery generation * no-mistakes(document): Document Claude long-turn supervision exception * no-mistakes(lint): Fix empty ShellCheck assignment * no-mistakes(ci): Fixed all reported CI failures: quoted the hyphenated recovery-delivery value to satisfy ShellCheck SC2100, and updated the session-lock auto-arm fixture to emit the recovery marker and watcher beacon now required for a valid rewake. Verified fm-session-lock-ancestry, fm-test-run, stale-banner, Claude auto-arm, targeted lint, ShellCheck, and workflow lint checks pass * fix(herdr): recover gone and drifted worker endpoints (#4120) * fix(herdr): classify a gone session's endpoint as recoverable A task whose Herdr endpoint could not be read was classified `unreadable`, which blocks recovery by design. The commonest reason that read fails is that the recorded session's server is not running at all - a host reboot, a server exit, a session never restored - and that is authoritative absence for every pane in that session, not an ambiguous answer about one of them. Tasks in that state had no sanctioned way back. The recovery-grade read now settles an uninterpretable pane read with the session server's own `.server.running` state: positively stopped reads `missing`, while a running server, or a server state that cannot itself be read, still reads `unreadable`. Resting the verdict on that field rather than on the `server_not_running` error code is what keeps it working across Herdr 0.8.x and 0.9.0, since the field is present on both and the code is not. Only that one boundary is widened. The husk classifier under it stays strict, so duplicate prevention, rollback, and teardown - the paths that can destroy something - keep refusing on exactly the reads they refused on before. Separately, a relaunch refused outright when the endpoint's shell had drifted out of the recorded worktree. An agent's own exit routinely leaves its shell somewhere else, so that refusal stranded tasks whose work was sitting untouched on disk. The shell is now told once to return, and only a shell that will not go refuses; the replacement still never starts outside the copy holding the work. Herdr 0.8.x is not installed on this host, so protocol-20 coverage is structural plus the adapter fixture exercising both response shapes, and is recorded as such rather than as a live result. Fixes #4091. * no-mistakes(review): Restrict drift recovery to Herdr endpoints * no-mistakes(review): Correct Herdr recovery verification coverage * no-mistakes(document): Document Herdr endpoint recovery boundaries * fix(bin): prevent receiver wake failures from blocking remote handoffs (#4033) * fix(bin): keep an escalated undelivered handoff wake retryable A remote backlog handoff holds its outbox until the backlog receipt and the receiver wake are both confirmed, and retries the wake under the same pending-reply correlation on every resume. When that wake's remote transport was lost, the correlation stayed undelivered in delivery_unknown and the watcher's next pending-reply tick escalated it. Both the reuse predicate and the known-undelivered reset refused an escalated record, so the resume refused to resend the wake forever and every later handoff to that mate jammed behind the outbox. Treat an escalated record with no confirmed delivery as the undelivered correlation it is: fm_pending_reply_corr_reusable accepts it for its own task and fm_pending_reply_reset_known_undelivered returns it to awaiting_report for the idempotent remote resend, while a delivered record is still never reset and a missed-report escalation keeps its meaning. The published delivery-unknown decision stays open until the record resolves, so a repeat loss neither re-notifies nor strands it. Reproduce the deadlock end to end in the remote handoff test (lost wake transport, watcher escalation, resume) and pin the predicate contract in the pending-reply suite; the fm-send fixture that pinned the refusal now uses a genuinely stale delivered escalation. * no-mistakes(review): Decouple durable outboxes from best-effort wake retries * no-mistakes(review): Align handoff documentation with durable receipt release policy * no-mistakes(review): Handle unrecordable wake state as dropped * no-mistakes(review): Prevent stale wake markers blocking handoffs * no-mistakes(review): Prevent stale delivered markers suppressing new wakes * no-mistakes(document): Clarify retry escalation decision lifecycle * no-mistakes(document): Document pending receiver wake retries * fix: restrict captain address rule to user chat (#4075) * docs: bound the mandatory captain address to the chat channel AGENTS.md's opening address rule said "address the user as captain at least once in every response" and never said what a response is. The artefact exclusion two lines below governed only the optional nautical seasoning, not the mandatory address. An agent that reads this file without being the first mate - a pipeline corrector agent running inside a copy of this repo - therefore read the obligation as applying everywhere and the exclusion as applying only to flavour, and opened its delivery message with "Captain,". That reading was correct. Patch the existing owner rather than adding a rule elsewhere: - bound the obligation to chat messages sent to the captain; - state the artefact exclusion once, explicitly binding every agent that reads this file whether or not it is the first mate, and naming commit messages, PR and issue descriptions, briefs, code and comments; - fold the seasoning under the same bound instead of carrying a second, narrower copy of the exclusion. The obligation itself is unchanged: the captain is still addressed in every chat message. AGENTS.md goes from 603 to 602 lines: the redundant "never send a response with zero direct address" clause and the duplicated seasoning exclusion pay for the new bound. The two cross-references that paraphrased the unbounded wording (bin/fm-parent-channel-lib.sh's header and docs/secondmate-parent-channel.md's problem statement) now match the owner; neither restates the rule. * fix(review): Limit address exclusions to artifacts while preserving public replies * fix(document): Consolidate captain address guidance * fix(herdr): allow detached teardown of persisted-focused tabs (#4131) * fix(herdr): close persisted-focused tabs when no live client is attached The teardown active-tab guard treated Herdr's last-focused pointer as a live viewer, so detached sessions could not close panes on that tab. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(herdr): allow detached seeded-tab prune after live-client gate Projection create still restored the persisted focused tab after a successful prune, so a detached last-focused seeded tab still quarantined the spawn. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(herdr): probe live client after seeded prune only when that tab was focused The extra title-clear read after every prune shifted canned CLI fixtures and failed projection create. Co-authored-by: Cursor <cursoragent@cursor.com> * no-mistakes(review): Tighten Herdr active-tab close guard * no-mistakes(review): Guard Herdr mutations with fresh target focus * no-mistakes(document): Document Herdr live-viewer teardown guard --------- Co-authored-by: Cursor <cursoragent@cursor.com> * fix(bin): escalate decision-owned wakes once as the decision (#4169) * fix(bin): escalate decision-owned wakes once as the decision The away-mode daemon treated a needs-decision: queued payload as an unknown wake, so suppression markers never committed and the same open decision re-escalated on every poll. Classify that payload through the existing signal path so it escalates once, labelled as the decision, and an unchanged repeat is suppressed on the same terms as any other signal. Fixes #4096 * no-mistakes(review): Escalate captain-held decision-owned rows once as the decision * no-mistakes(review): Self-handle captain-held decision-owned rows instead of escalating them * no-mistakes(document): Name away daemon as needs-decision payload reader * test(herdr): cover agent exit-to-shell liveness (#4172) * test(herdr): pin leftover-shell vs live-idle via agent get Herdr 0.9.0 already distinguishes a Pi that exits to a surviving pane shell from a sibling live idle occupant. Pin that pair through agent get and the recovery classifier so a lagged pane-get status cannot silently reclaim the leftover shell as alive. Co-authored-by: Cursor <cursoragent@cursor.com> * no-mistakes(document): Document Herdr leftover-shell liveness regression * no-mistakes(ci): Fixed Lint failure SC2034 by replacing the unused wait-loop variable with `_`. Verified with the pinned project lint command, Bash syntax check, and git diff check --------- Co-authored-by: Cursor <cursoragent@cursor.com> * fix(bin): rebalance portable parallel test lanes using CI timings (#4151) * ci: rebalance the portable parallel lanes on measured runner durations Both portable parallel lanes are capped at 10 minutes. Lane 1 was cancelled at that cap on every request raised on 2026-09-10 while lane 2 finished in about 3.5 minutes, so no request could go green. CONTRACT CLASS: RESTORE. The workflow already promises two duration-balanced lanes and the shard documentation already claims a measured wall; this re-establishes both against what the lanes now cost, and changes no lane count, no cap, and no scope of what runs. The counter-argument, so nobody has to take that on trust: two pieces here are genuinely new rather than restored, and either could be argued to make this a NEW-behavior change. `--list-scheduled` now ranks a parallel lane on measured durations where it previously handed every parallel script the serial default weight and returned an alphabetical order; and `--check-coverage` gains three reported fields. I classify the change RESTORE because both exist only to make the already-promised property checkable, but they are named here rather than folded into the restoration. === PART 1: THE TOTAL, AND HOW IT WAS OBTAINED === This section stands on its own. It establishes what the parallel set costs. It derives no packing; Part 2 does that, from this number. THE TOTAL: 828568 ms, about 13 min 49 s of serial work across the 24 scripts. Lane 1 held 624299 ms of it and lane 2 held 204269 ms, a 3.06:1 split. HOW IT WAS OBTAINED. The difficulty was that lane 1 had never finished, so its duration did not exist as a recorded figure anywhere and no timing artifact was expected for it. It turned out to be recoverable from the real lane without estimating, by two routes, across six CI runs on 2026-09-10 (34459949083, 34460760299, 34462530836, 34462758357, 34466966385, 34470382458): - Run 34462758357's lane-1 job finished its suite 18 s BEFORE the wall and uploaded a complete fm-test-timing-portable-parallel-1 artifact carrying all 11 scripts, FM_TEST_SUMMARY total=11 failed=0 duration_ms=598225. The upload step is if: always(), so the cancellation did not suppress it. This is one full, untruncated lane-1 measurement. - The five other lane-1 jobs were cancelled mid-suite, but each logs every script that had already finished as an FM_TEST_END duration_ms= marker. Those per-script records are complete measurements of completed scripts; only the script in flight at cancellation is lost, and it differs by run. Lane 2 completed in all six runs, so its scripts come from the six uploaded fm-test-timing-portable-parallel-2 artifacts. Every one of the 24 scripts therefore carries at least one untruncated measurement: 20 of them measured in all six runs, two in three or four runs, and two (fm-brief, fm-transition-lib, the tail of lane 1) in the single complete run. Each hint is the SLOWEST value that script reached, so the total is an upper envelope rather than an average. NO FIGURE IN IT IS DERIVED FROM A TRUNCATED LANE, and no lower bound was ever extrapolated into a total. THE ENVIRONMENT, AND WHETHER IT TRANSFERS. Every hint is a serial run of the real portable parallel lane on a GitHub ubuntu-latest runner, produced by the lane's own CI job. It transfers because it is not a proxy for the lane; it is the lane. Nothing in the total came from this machine or from any harness of mine. That mattered, and here is what it would have cost. A same-day macOS cross-check of the same scripts ran 1.7x to 5.0x slower with the ratio varying per script (fm-test-run 157420 ms against 92944 ms, fm-x-mode 67217 ms against 31870 ms, fm-composer-ghost 10521 ms against 2120 ms). Local timings therefore do not scale the lane, they REORDER it, so a packing derived from them would have balanced the wrong thing while looking clean. WHAT IT REPLACES, which is the root cause. The lanes were packed from the 2026-08-20 concurrent isolation proof: 24 candidates across four LOCAL workers. That record answers whether the candidates are isolation-safe, not how long a SERIAL CI lane runs, so it was structurally incapable of representing lane wall clock even when it was fresh. It was also never refreshed while the set grew about 3.2x. Both the wrong instrument and the staleness are fixed here: the hints now come from the lane itself and carry their run ids and date. === PART 2: THE SPLIT DERIVED FROM THAT TOTAL === Longest-processing-time assignment over those hints gives 414269 ms and 414299 ms, 30 ms apart, against 624299/204269 before. tests/fm-pi-primary-types.test.sh stays in lane 1 because that is the job which installs the Pi package, so ci.yml needs no step changes. === PART 3: DOES THE MARGIN SURVIVE MACHINE VARIANCE === Stated explicitly, because 6.90 min against a 10 min cap is 69% of cap before any variance is applied, and the cap covers the whole job rather than the suite. worst lane, script time 414299 ms 6.90 min job overhead, measured on the real lane ~18 s (see below) expected healthy job ~432300 ms 7.21 min x1.29 on the script time, plus overhead ~552400 ms 9.21 min cap 600000 ms 10.00 min room left after the multiplication ~47.6 s 7.9% of cap The 1.29x is the runner variance measured today on the SIBLING SERIAL lane, as supplied; it is not this lane's own figure. This lane family does have its own, and it is tighter: the six full lane-2 sums today span 192939 ms to 203451 ms, a spread of 1.054x. At that figure the worst lane lands near 7.58 min with about 2.4 min of room. I have used the LARGER, borrowed 1.29x for the verdict rather than the tighter one this lane actually shows, and note that the hints are already per-script maxima, so 1.29x on top is conservative twice over. THE MARGIN SURVIVES THE MULTIPLICATION, so this proceeds rather than stopping. The 18 s overhead is measured, not assumed: in run 34462758357 the lane-1 job ran 10 min 16 s against a 598.2 s suite, and lane 2 ran 3 min 21 s against a 192.9 s suite, a ~10 s difference that matches lane 1's extra Pi package install. The cap is unchanged, the lane count is unchanged, and nothing in the serial lane, its shard count, its guard or its hint table is touched. === PART 4: THE RECORDED FACT === The workflow comment no longer restates the shard wall as a literal, which is how "~1 min of serial sum" survived a 10x change without announcing it. It now points at bin/fm-test-run.sh --check-coverage, which prints parallel_max_ms, parallel_imbalance_ms and parallel_unhinted derived from the hint table, so the current number is computed on demand. The shard documentation carries the dated run ids, which route it was taken by, and the local cross-check that shows why local numbers are not admissible as hints. Two regressions pin what rotted: lane membership must be stored longest-measured-first, and the lanes must be fully hinted and packed within 5% of each other. Both were run against the old composition and both fail on it (420030 ms imbalance against a 624299 ms worst lane). The ordering assertion they replace named a specific script by hand and had itself gone stale. === PART 5: NAMED AND LEFT, OUTSIDE THIS REBALANCE === tests/fm-captain-hold-lifecycle.test.sh alone is 296481 ms, 36% of the whole set, so it is the floor of any two-lane split: no repacking can put a lane below it. After this rebalance the cap is about 1.45x the healthy lane where the sibling serial lane keeps roughly 2x. Nothing refuses a stale parallel hint the way PORTABLE_SERIAL_MAX_UNHINTED_PERCENT bounds the serial lane. parallel_unhinted is reported, not enforced, which is what let this drift for three weeks unnoticed. * fix(review): Restrict parallel scheduling hints to portable parallel lanes * fix(document): Clarify parallel lane scheduling and timing evidence * fix(bin): stop claiming prose-mentioned PR URLs as a task's delivered PR (#4148) pr_for_task fell back to scraping the whole status log with tail -1, so any PR URL a worker ever mentioned in prose - including a scout citing someone else's PR - became the task's delivered PR in the parent-channel terminal report. Recorded meta pr= is now the only authoritative source, the fallback scrape accepts only a preferred terminal line in a mode's ready-signal shape (done: PR <url> or done: PR <url> checks green), and a scout never carries pr= at all. * fix(procevent): confirm reconcile launches and reclaim provably dead claims instead of counting a dead drop as started (#4212) * fix(procevent): stop a dead runner owning a source and reconcile reporting it The captain answered ten calls on a bearings board, the board accepted them, and nothing collected them. He had to answer all ten again in chat. A surface that presents as armed while being a dead drop is worse than one that visibly fails, because the answers looked recorded. Two independent defects, reproduced together in an isolated home where reconcile reports started=1 on every run while ownership never moves and no runner ever attaches. 1. reconcile counted a launch it never verified. detach_runner is fire-and-forget and discards the child's stderr, so a runner that died before it could claim was counted exactly like one that is listening. Launches are now confirmed - the source observed owned, or its runner record moved - before being reported as started; the rest are reported as failed= with a non-zero exit. The runner-record clause is what keeps a fast-completing source from being reported as a failure when it finished between two polls. One bounded window covers a whole cycle's launches, so a home full of broken sources costs the same wait as one. 2. A claim whose whole generation is provably gone could be refused forever. Reclaiming it ran cleanups over that dead generation's own leftovers, and any failure vetoed the claim - permanently, because none of those conditions clears on its own. Every one of those leftovers is keyed by the dead generation's claim token and a replacement always claims a fresh one, so none can collide with what replaces it. fm_procevent_claim_capture_reservation_reclaim_locked already said this for the reservation record; the staging file and the shape check on the registry directory recorded to hold it now take the same rule. Removing the claim record itself stays a hard precondition: two owners is the one outcome worse than none. Two smaller repairs to the same "registered is not listening" confusion: - `list` reported OWNER=none for a source nothing can claim. A reused PID whose process group survives reaches that state through the stale branch rather than the leaderless one, so it read as an idle source waiting to be started - the reassuring answer this surface gave while a board collected nothing. It now reports the orphaned state it shares. - reconcile relaunched into that same unclaimable state on every cycle, spawning a runner that could only die on the claim. docs/configuration.md already promised it preserves such a claim without starting a replacement; the code now does that and reports it as uncertain. This is NOT a third instance of today's two lock-identity defects (4e1bf9aa and its replayed predecessor). Those were wrong liveness predicates: a reused PID read as a live holder, then an exec'd holder read as dead. Here the predicate is right - the code correctly proves the owner dead and refuses the claim anyway, on a condition unrelated to liveness. Regression coverage, each failing on the parent commit for its own reason: - tests/fm-procevent.test.sh: a source that cannot start is reported as failed rather than started; a dead generation whose leftovers cannot be tidied no longer keeps owning its source (the parent reports a start while nothing ever runs); the existing reused-PID fixture now also asserts the orphaned listing and that no doomed relaunch is reported. - tests/fm-captain-hold-lifecycle.test.sh: a board answer reaches the keyed-answer intake through the runner end to end - durable capture, the wake, and the closed task carrying the captain's selection. This one passes on the parent, because that chain was never what broke. fm-procevent 100, fm-bearings-board 18, fm-captain-hold-lifecycle 50, fm-procevent-when 13 and fm-procevent-quota 18 pass; bin/fm-lint.sh and bin/fm-doc-audience-check.sh clean. tests/fm-extension-binding.test.sh has two failures identical on the parent commit (EACCES on package install in this sandbox) and unrelated to this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gxgshn5jkWJ3GEYWy7vTG * no-mistakes(review): confirm reconcile launches on durable launch stamps * no-mistakes(review): announce stranded sources and refuse bad confirm windows * no-mistakes(review): announce leaderless strands, bound confirm window, fix recovery docs * no-mistakes(review): announce unconfirmed launches once per episode, qualify start reclaim * no-mistakes(review): nonce launch-failed keys, refuse bad window at arm * no-mistakes(review): state only observed launch outcome, shorten episode nonce * no-mistakes(test): assert launch-failed headline not re-delivered, allow recovery wake * no-mistakes(document): docs: cover strand and launch-failure wakes in skill trigger and verification record * no-mistakes(lint): restructure SC2015 chain into explicit if-block * test(watch-triage): fix two timing-exposed defects the pipeline found Both surfaced in the no-mistakes test step on this branch, each failing one full run of tests/fm-watch-triage.test.sh; neither was accepted as a flake to retry past. 1. The new launch-failed delivery test assumed an already-surfaced key never wakes the watcher again. That is false: a fresh watcher legitimately re-surfaces any unacknowledged queue row through its downtime-recovery path ("check: rearm-resurface"), so the assertion failed whenever a re-arm landed between its two checks. The pipeline's own fix tolerated any wake lacking the repeated key's headline; this tightens it to exactly one tolerated reason, by its exact line, with a failure message that names the expectation so a reworded path reads as "the tolerated recovery path changed" rather than as a mystery - and so nobody restores the strict silence check. The positive assertion (a fresh-suffix key is delivered under its own headline) is unchanged. 2. seed_captured_procevent_result retired its source in the gap between the runner publishing its wake and releasing its claim, so retire read the exiting runner's ownership as uncertain and refused ("cannot confirm runner identity"). The fixture and retire path pre-date this branch; the confirm window returns reconcile closer to the moment of capture, which made the gap easier to hit. The fixture now waits, bounded, for the claim release the publish promises, with the reason at the wait. Verified on this head with tasks-axi on PATH: fm-watch-triage 113/113 with no skips, fm-procevent 106/106, fm-captain-hold-lifecycle 50/50, fm-watch-arm 15/15, fm-bearings-board 18/18, fm-procevent-when 13/13, fm-procevent-quota 18/18; bin/fm-lint.sh and bin/fm-doc-audience-check.sh exit 0. First attempt, no retries. * no-mistakes(document): docs: route stranded and launch-failed wakes in skill handling --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(herdr): verify agent liveness at process level before trusting registration (#4191) * fix(herdr): verify agent registrations at process level before trusting them Herdr keeps a Pi registration (`agent get` -> agent=pi, agent_status=idle) after the Pi process has exited to a plain shell whenever a nested interactive shell sits under the pane's top shell, which is the crew shape `treehouse get` leaves behind. The pane classifier trusted that registration alone, so `fm-control.sh <id> relaunch`, `fm-spawn.sh --relaunch`, and the crew-state recovery read all treated a shell-only pane as a live agent and refused recovery for as long as the record lived. The Herdr adapter now reads `pane process-info` plus the real process table through a shared harness-process classifier (bin/fm-agent-process-lib.sh, moved verbatim out of the tmux adapter so both backends mean the same thing by agent, shell, and other) before a registered agent counts as live. A registration over a shell-only pane is the new explicit `stale-agent` pane state, which the recovery-grade read maps to `dead`; husk detection, reclaim, presentation recovery, and session cleanup keep refusing it, so recovery reuses the pane and nothing gains close authority. A working record is verified the same way before the native busy verdict reports busy, so the recovery classifier never reports a shell-only pane as working. An unreadable process view reads unknown, trusting neither the registration nor its absence. Reproduced and measured on Herdr 0.9.0 with Pi 0.85.1 in an isolated lab; the new default-on live guard tests/fm-herdr-pi-stale-registration-live-e2e.test.sh exercises the real stale record, tests/fm-control-herdr-smoke.test.sh proves exit and relaunch through the control plane, and the portable suites pin the classifier over real processes. Fixes #4115. Duplicates: #3639, #3487, #2908, #3545. * no-mistakes(review): settle transient prompt helpers before trusting herdr process state * no-mistakes(review): drop stray codegraph file; read spaced comm whole in descendant walk * no-mistakes(review): untrack stray .codegraph/.gitignore * no-mistakes(review): untrack codegraph file; make spaced-path walk test discriminating * no-mistakes(review): untrack stray .codegraph/.gitignore * no-mistakes(review): untrack stray .codegraph/.gitignore re-added by fix round * no-mistakes(review): untrack stray .codegraph/.gitignore * no-mistakes(review): untrack codegraph file, drop dead control case, record process-info floor * no-mistakes(review): refuse stale-agent on fresh herdr spawn preflight Documented non-goal: fresh-spawn, reclaim, and presentation-recovery auto-recovery for a stale-agent pane is a separate design change, out of scope here, to be proposed upstream as its own issue if wanted. * no-mistakes(test): Fix herdr flake: don't misread transient empty foreground as unreadable * no-mistakes(document): Add fm-agent-process-lib.sh to scripts inventory * no-mistakes(fix): update remote herdr fixture to the real pane process-info shape The shared remote-secondmate herdr fixture still returned the old flat process-info body ({"result":{"process":{"name":...}}}). The process-level liveness classifier added for #4115 requires the real {"result":{"type":"pane_process_info","process_info":{...foreground_processes}}} shape and treated the old body as unreadable, so an already-launched remote endpoint's agent-state read failed and any relaunch attempt against it died with "remote endpoint state is unreadable; refusing duplicate launch" instead of reaching the state it was actually exercising (tests/fm-remote-secondmate-parent-binding.test.sh, tests/fm-remote-secondmate-lifecycle-e2e.test.sh). * no-mistakes(review): test: add empty-foreground regression test for herdr flake fix * no-mistakes(document): docs: register new stale-registration live-e2e test in herdr entry points * feat: add live-head merge gates and away task grants (#4199) * Bind GitHub merges to a live green head and require an away-task grant. A GitHub merge now re-reads the pull request and passes --match-head-commit, so a red or moved head cannot land the way GitLab already refused. While an away record exists, only yolo or a named grant may merge, so hold-for-return cannot ship an ungated PR. Co-authored-by: Cursor <cursoragent@cursor.com> * no-mistakes(review): Harden away merge authorization and grant parsing * no-mistakes(review): Restrict fallback outcomes to proved GitHub merges * no-mistakes(document): Refresh merge safety documentation * no-mistakes(ci): Fixed all three CI failures by updating legacy GitHub merge fixtures for live-head verification/direct gh merges and removing a process-event runner cleanup race. Verified fm-pr-check-security, fm-captain-hold-lifecycle, and fm-watch-triage pass locally; shell syntax and git diff checks also pass * no-mistakes(document): Document attended red-check exception --------- Co-authored-by: Cursor <cursoragent@cursor.com> * fix(bin): bound the Claude turn-end re-block against a frozen auto-arm epoch (#4221) The --claude guard's re-block budget charged the auto-arm ledger epoch, not the re-block: `budget_account_current_epoch` advanced the session count only when `state/.claude-autoarm-epoch` named a different generation than the previous accounting. The epoch advances only inside the auto-arm hook's generation claim, so a hook kept inert before that claim - a session lock held by a live harness outside its ancestry, a hook that never fires, or an identity or write failure ahead of `fm_autoarm_claim_next` - left the ledger frozen at its last outcome and the count frozen with it. Reproduced in a fixture: twelve consecutive Stops re-blocked with the count at 0 and the attended fail-open never fired, leaving only Claude's silent 8-block override, the blind end the bounded alarm exists to prevent. The budget now charges a re-block against an epoch the previous re-block already charged, while still charging each epoch at most once per Stop so the wait loop's repeated observations of one fresh terminal outcome and the same invocation's block decision cannot double count. The advancing-epoch progression is unchanged: three re-blocks, then one attended fail-open for a verified failure episode, and a frozen epoch now follows the same shape. Budget exhaustion without a verified failure still blocks, by the existing contract, and positive watcher recovery still clears the whole episode. Regression coverage drives the real auto-arm hook against a foreign session lock holder, asserts the ledger itself stays frozen, and fails before the fix in both the verified and unverified shapes; the existing unverified budget test now proves its budget actually ran out. * feat(herdr): add guarded foreground viewer for live validation (#4242) * feat(herdr): attach a real foreground viewer so the live-client teardown cases can be driven PR #4131 gated the Herdr active-tab close refusal on a live foreground client instead of the persisted `.focused` pointer, but only its two detached scenarios could be validated live. Every pseudo-terminal the runner built started at a zero-sized window grid, so Herdr registered no foreground client and `terminal title clear` kept answering `no_foreground_client`, leaving the four attached-client scenarios untested. That was a harness limit, not a product one. Add `fm-herdr-lab.sh viewer start|stop <session>`, backed by `bin/fm-herdr-lab-viewer.py`. The launcher sets the pty window size on the master fd BEFORE the fork, so the TUI cannot read the grid until it is already non-zero, and scrubs the inherited `HERDR_*` variables so Herdr's nested-viewer refusal does not fire when the helper runs inside one of its own panes. Attach and detach are both confirmed against the session's own foreground-client reason rather than assumed from a signal. The viewer inherits the lab's isolation contract: it attaches only to a session carrying this lab's ownership tripwire, never to `default`, and it signals only the processes it recorded, so a client someone else attached is never touched. Teardown now refuses while an owned viewer is still attached. Turn the reproduction into the regression with `tests/fm-herdr-attached-viewer-live-e2e.test.sh`, which drives #4131's scenarios 3, 4, 5, and 7 live against real Herdr and asserts the close refusal fires. Scenarios 4 and 5 need a focus change at one exact product boundary, so a PATH shim performs the real `tab focus` when the close helper issues its planning `pane get`. Removing either half of the recipe from the launcher makes the guard fail with the same `no_foreground_client` symptom #4131 reported. * test(herdr): fail loudly when an attached-viewer fixture cannot be created The fixture helpers run inside command substitutions, where fail() exits only the subshell and leaves the script running with empty ids. Return non-zero instead and carry the message at each call site. * fix(herdr): stop the viewer launcher's kill timer from raising on an exited child The SIGALRM escalation called os.kill unguarded, so a viewer that exited during the grace window turned an ordinary shutdown into a traceback inside the signal handler. * docs: list the lab viewer's pty engine in the bin toolbelt * no-mistakes(review): Harden Herdr viewer ownership and live CI coverage * no-mistakes(review): Validate viewer startup timeout and process ownership * no-mistakes(document): Document Herdr viewer safety contracts * no-mistakes(review): Fix viewer timeout to two seconds * no-mistakes(review): Cancel timed-out viewers and fix PTY grid * no-mistakes(review): Serialize viewer transitions and verify process parentage * no-mistakes(review): Harden viewer ownership locks and deduplicate CI * no-mistakes(review): Release interrupted locks and preserve viewer escalation * no-mistakes(review): Remove viewer locks and cancel interrupted launches * no-mistakes(review): Close viewer launch signal races * no-mistakes(document): Document attached Herdr viewer regression * fix(bin): let nonvisual work proceed when lavish-axi is unavailable (#3766) * fix(bootstrap): allow nonvisual work without Lavish * no-mistakes(review): Gate scout brief Lavish line on bootstrap version floor * test: isolate fixture Git config from host global and system settings (#3825) * fix(tests): isolate fixture Git configuration from host preferences Ignore global and system Git configuration in the shared test library, which all four fixture helper entry points source. Keep local config, command-line overrides and explicitly supplied test config usable without changing the caller's environment or real project signing preferences. Exercise global and system signing inputs through all four helpers, real fixture and child commits, explicit signing overrides, unchanged input files, and signing refusal outside fixture subprocesses. Verification evidence for issue #3770: On pristine upstream f09de8a3, all 12 reported suites failed and each logged "No secret key" using a private GIT_CONFIG_GLOBAL containing commit.gpgsign=true and gpg.format=openpgp, GIT_CONFIG_NOSYSTEM=1, and an empty private GNUPGHOME (GIT_CONFIG_COUNT and GIT_CONFIG_PARAMETERS unset). With this change, all 12 pass in the identical environment through bin/fm-test-run.sh --per-script-timeout-secs 900: fm-backlog-atomicity, fm-bootstrap-network-parallel, fm-bootstrap, fm-crew-state, fm-fleet-sync, fm-gate-refuse, fm-grok-harness, fm-session-start, fm-sessionstart-nudge, fm-tangle-guard, fm-test-run, and fm-update (all tests/<name>.test.sh). The new fm-test-fixtures regression failed before the library change and passes after it. Canonical bin/fm-lint.sh passes. Additional verification exposed fm-teardown's herdr-preflight-missing-adapter assertion on both this branch and an unchanged f09de8a3 archive with signing neutralized. That pre-existing failure needs separate disposition; it is not repaired or skipped here. The separately owned Muse and composer fixture defects remain untouched. Fixes #3770 * no-mistakes(review): Complete fixture Git isolation and scope config assertions * no-mistakes(review): Share Git isolation across standalone fixture entry points * no-mistakes(review): Map git-config helper changes to lib.sh dependents * no-mistakes(review): Select fixture-isolation regression on runner change; halve config matrix * no-mistakes(review): Scope fixture-isolation regression selection to the runner alone * no-mistakes(document): Give fixture Git isolation helper its owning header * no-mistakes(document): Record fixture Git-isolation coverage in fixtures suite header * no-mistakes(review): Fix linked-worktree fixtures and remove redundant Git isolation * no-mistakes(document): Correct stale runner-selection documentation * no-mistakes(document): Clarify family antecedent in isolation-proof runner evidence * feat(bin): add config/claude-permission-mode to launch Claude workers in auto mode (#4239) * feat(spawn): add config/claude-permission-mode to launch Claude workers in auto mode Every Claude worker launched with --dangerously-skip-permissions, and a captain who refuses bypass mode had no way to select Claude Code's classifier-reviewed auto mode instead. A new one-token local config, config/claude-permission-mode, selects the permission flag for every Claude launch: absent or `bypass` keeps today's launch byte-for-byte, `auto` swaps in --permission-mode auto, and any other value refuses the spawn before any endpoint, worktree, or record exists and names the accepted values. fm-spawn resolves the file on every spawn and relaunch, threads the flag through the Claude launch template for crewmates, scouts, and secondmates alike, and records claude_permission_mode=auto in the task meta only under auto so the default meta stays unchanged; a relaunch re-resolves rather than preserving the line. The file is a captain-wide safety preference, so it joins the inherited local material pushed into secondmate homes. The Claude adapter reference records the verified auto launch shape on Claude Code 2.1.269 and that it never meets the once-per-machine bypass confirmation dialog; docs/configuration.md owns the schema. * no-mistakes(review): drop unread claude_permission_mode meta line and its assertions * fix(teardown): leave a Treehouse pool slot reassigned to another task untouched (#4243) * fix(teardown): refuse to return a Treehouse pool slot reassigned to another task A pool slot is reused across tasks, so a finished task's worktree= line can name a slot a different, live task now holds. Teardown already refused when a second task record named the same live path, but that scan cannot prove the record it is tearing down is the current owner: the task that took the slot next may leave no record the scan can reach - its own worker may have exited and its record been cleaned up, or it may live in a home this machine does not register. Teardown then killed every process under the path, hard-reset it and returned it, and its unlanded-work refusal never fired because it was inspecting a directory that no longer belonged to the task being torn down (observed 2026-09-07). Treehouse's own state file cannot answer the ownership question. It records a slot's owner as a live process lease (owner_pid plus owner_started_at, with `treehouse status` reporting in-use from the processes actually running under the path), which names no task and is released by the very event that makes a record stale - the worker exiting. An unleased slot therefore reads identical whether it is still this task's or has since been handed on, and a slot whose new holder has also exited but left uncommitted work reads as free. So the identity source is Firstmate's own claim, not Treehouse's lease. fm-spawn writes that claim - the task id - into the slot at the moment it takes it, under the same project lock that allocates the slot, and fm-teardown drops it only after the slot is genuinely returned. It lives at <pool>/<slot>/.fm-slot-owner, a sibling of the repo checkout rather than a file inside it, so claiming a slot can never dirty the copy the landed-work checks inspect. A claim naming another task, or one that cannot be read, refuses; --force does not lift either refusal, because --force authorizes discarding this task's unlanded work, never another task's live work. A slot that cannot be claimed refuses the spawn instead. An absent claim proceeds on exactly the record-scan protection it had before: slots taken before claims existed, and slots already returned, carry none, and refusing those would strand every task in flight across this change on no evidence at all. The refusal is deliberately all-or-nothing rather than partially completing the task's own cleanup. state/<id>.meta is the only durable record naming the worktree and endpoint, so removing it would destroy the evidence needed to reconcile which record is wrong, and its removal is one step with the backlog transition. Nothing is stranded: clearing the stale worktree= line leaves a record with no slot to release, which then tears down normally, and the refusal names that remedy. Repairing the previous claimant's stale worktree= line at spawn time is left for separate work. It would have the new owner write another task's record - the same class of cross-task mutation this bug is - and would need that record's own meta lock; with the claim in place teardown refuses on evidence rather than depending on the stale pointer having been scrubbed. For the same reason the relaunch path writes no claim: it holds no allocation lock, and a record whose worktree= is already stale would stamp the wrong task's claim onto a live sibling's slot. The regression reproduces the reuse sequence with only one discoverable record, including a clean, fully landed ship copy torn down without --force - the shape of the real incident, which the previous code returned to the pool - and fails against the previous code; the existing two-record, cross-home, own-slot and no-claim cases still pass unchanged. This builds ON upstream b028e8b1 (#3837), which is already in this branch's base (origin/main 40c50ea8) and owns the record-exclusivity scan. Nothing here replaces that scan; the claim is the positive proof it cannot supply. Claude-Session: https://claude.ai/code/session_01JTBmuqKugaPUj7k9TXQwFS * no-mistakes(review): teardown leaves reassigned slot; spawn abort drops claim * no-mistakes(review): narrow Treehouse lease evidence; gate abort claim release on lock * no-mistakes(review): pin spawn-side slot claim; narrow abort-release header * no-mistakes(document): docs: point slot-claim rationale at fm-wake-lib owner * docs(AGENTS): keep brief-fill from widening the captain's ask (#4247) The reviewer treats Captain's intent as acceptance criteria, so a widened ask there drives over-built work; the spec should carry only what the ask requires. * fix: identify underway tasks and sort charted work (#4245) * feat(bearings): name the Underway rows and order Charted Next newest filed first The fleet board's Underway rows led with the run status alone, so a scan told the captain where a pipeline stood but never which task the row was, and Charted Next rendered in backlog order rather than by when work was filed. The snapshot now projects the durable task name onto every in_flight row - from this home's backlog title, and from a secondmate home's own ledger for an active child - and the durable filed date onto every gate. The board's Underway row leads with that name and keeps the run status on its second line, and Charted Next renders newest filed first, with rows carrying no comparable date keeping their payload order after every dated row. The payload validator requires an explicit name marker on every Underway row and refuses a filed value that is not an ISO date, so the board can never sort on garbage or invent a label. * no-mistakes(review): Fix Bearings labels, bounds, and filed validation * no-mistakes(review): Fix Bearings identifiers and eligible queue bounds * no-mistakes(document): Document Bearings labels and newest-first bounds * no-mistakes(ci): Updated the stock macOS Bash CI expectation from 56 to 59 Bearings tests. Verified the suite under /bin/bash 3.2: all 59 tests pass. git diff --check also passes * fix(bearings): surface return catch-up without blocking snapshots (#4248) * fix(bearings): report the away-return catch-up instead of refusing A captain returning from away and asking for bearings got zero bytes and an error: fm-bearings-snapshot.sh ran the away-return guard with `|| exit $?` before reading any fleet state, so the mere existence of the catch-up gate killed every bearings mode (and /ahoy with them). Bearings now consults that guard rather than obeying it. fm-afk-return.sh separates its two refusal branches by exit status, so an ACTIVE away window still refuses exactly as before - the right answer there is to run the return first - while return catch-up (exit 4) lets collection and projection proceed and is disclosed as one action-free `(return-catchup)` gate row, following the existing `(main-inventory)` precedent. It stays out of decisions_open: these blockers are firstmate-actionable, not the captain's own call, and the per-task blockers already project as their own Underway rows. The guard's refusal text also stops promising a blocker list it cannot produce: a gate retained for a lifecycle reason alone now names that retention reason, and bearings carries the same reason in the gate row's title. Reporting is not ordinary work. AGENTS.md already scopes the return hold to work rather than reporting, so only the /afk and bearings skills needed the correction. * no-mistakes(document): Refresh away-return Bearings verification * no-mistakes(review): Reserve catch-up gate outside Bearings truncation * no-mistakes(review): Preserve filed dates in catch-up gate output * no-mistakes(document): Document reserved catch-up gate projection * fix(bin): address the home's backlog from any directory and detect a forked code-root copy (#4223) * fix(backlog): address the home's backlog from any directory and detect a forked code-root copy A home outside the code root forks its queue: the tracked .tasks.toml names data/backlog.md relative to tasks-axi's working directory, so a bare tasks-axi call from the code root writes the code root's data/ while session start, spawn, and teardown use $FM_HOME/data. Linking the code-root copy into the home does not hold, because tasks-axi 0.2.4 writes by renaming a temp file over its target and rename(2) replaces a symlink: add, start, hold, and done from the code root each turn the link back into a regular file. The archive path is resolved against the working directory too, even with --file. bin/fm-tasks-axi.sh runs tasks-axi against this home's backlog from any directory, using the lifecycle transitions' existing addressing (run from the data directory's parent, pin <data>/backlog.md through TASKS_AXI_FILE). It keeps relative --to/--*-file arguments meaning the caller's paths, and refuses a caller --file, an unresolvable home, and a symlinked home backlog. The fm-send hold lookup, fm-public-followup, and the fm-decision-hold shim, which relied on cwd discovery, now go through it with an explicit FM_HOME and a cleared data override, so they keep addressing exactly $FM_HOME/data and an ambient TASKS_AXI_FILE cannot divert them; every agent-facing backlog command names it instead of bare tasks-axi. Bootstrap gains a detect-only BACKLOG_RECONCILE check, also run read-only: when the home's data directory is not the code root's, a code-root data/backlog.md or data/done-archive.md that is not the home's own file is reported as a fork, with the merge procedure in bootstrap-diagnostics. * test(teardown): assert the completion hint names bin/fm-tasks-axi.sh ready The completion hint now points at the home-addressed command instead of a bare tasks-axi call, so the dependency-cleared follow-up assertion checks for that command. * no-mistakes(test): clear ambient tasks-axi env in tests/lib.sh * no-mistakes(document): drop bare tasks-axi example from cd-guard doc * no-mistakes(lint): replace ls -A decoy listing with find for SC2012 * no-mistakes: apply CI fixes * revert: keep the compliance gate unchanged; the synchronize race is filed separately * fix: pre-register Claude trust for secondmate homes (#4262) * fix(spawn): pre-register Claude workspace trust for secondmate homes A claude --secondmate launch skipped workspace-trust registration entirely, so a standalone-clone secondmate home (an explicit ~/fm-homes/<id> path) had no store entry and its pane wedged on the "Is this a project you trust?" dialog before it read its charter. The step was gated on the task kind rather than on the harness, so the spawn's fail-closed guard had nothing to run against and reported a launch that could never start work. fm-claude-trust.sh gains a secondmate-home mode. A secondmate home is a whole firstmate instance, produced either as a leased worktree or as a standalone clone, so the linked-worktree test cannot decide it and the seed is the evidence instead: the .fm-secondmate-home marker must be a regular file this user owns naming exactly the id being spawned, the home must hold AGENTS.md and bin/, and each operational directory must resolve inside the home. That is the set fm-home-seed.sh writes and fm-spawn.sh's own home validation re-checks, so nothing wider than a home a secondmate spawn would launch into can earn home-level trust. The worktree path is unchanged, and still refuses a home. fm-spawn.sh now runs the registration for every claude launch and keeps refusing the spawn when it fails, rather than launching an agent that would wedge. * no-mistakes(document): Correct Claude secondmate trust guidance * fix: ignore superseded failed GitHub check runs (#4258) * fix(pr-merge): judge each required check by its current run When the base branch advances, GitHub cancels a pull request's in-flight run and re-triggers it. The cancelled run stays in statusCheckRollup beside the passing re-run, so the rollup can hold several runs of one check name at the same head while GitHub itself reports the pull request CLEAN. github_checks_not_green judged every run independently, so that superseded failure refused a genuinely mergeable pull request and pushed the operator toward a needless --allow-red. Group the rollup by the reported name and judge each check by its current run. Supersession is proven, never assumed: a name leaves the red set only when every one of its non-green runs is strictly older than one of its green runs, dated by the forge's own settled timestamp - a check run's completedAt once its status is COMPLETED, or a status context's createdAt - and only in the whole-second UTC form GitHub emits, which is the one spelling that orders correctly as plain text. A run with no such timestamp is never superseded, so a still-running, queued or undated run keeps its check red, and a name with no green run at all stays red. An unnamed entry is grouped alone so two unrelated unnamed checks are never treated as one. Every comparison is one-directional: it can only clear a failure a later success provably replaced, and never clears a check whose current run failed, is pending, or is missing. No other guard moves - the pull request must still be open, undrafted, mergeable, conflict-free and head-bound, and --allow-red still waives exactly its named check with every other check green. Live reproduction: PR #4224 read CLEAN with an old FAILURE and a newer SUCCESS for one check name and was refused; it now verifies, while #4208 and #4210, whose latest runs failed, still refuse. * no-mistakes(review): Use check-run start times for safe supersession * no-mistakes(document): Clarify GitHub check-rollup documentation * fix(bin): persist merge authority for poll-detected outcomes (#4266) * fix(merge): persist the merge authority on poll-detected merge outcomes The merge ledger tags a merge with the authority that permitted it while the away-posture record existed, but only the direct attended merge in bin/fm-pr-merge.sh recorded it. A merge the forge queued, or one the merge poll detected after the fact, published an untagged row, so exactly the merges no agent watched were the least auditable. bin/fm-merge-authority-lib.sh now owns that answer, read from the same structured sources the merge gate already used: the task's recorded yolo posture and the away-posture record's mechanical grant list, never prose. bin/fm-pr-merge.sh keeps its own refusal wording and gates on that answer; bin/fm-watch.sh only records it on the row its poll publishes, so reading the authority never becomes a second path to a merge. An unresolved answer records an untagged row rather than dropping the outcome or inventing an authority. * no-mistakes(review): Persist canonical merge authority for queued poll outcomes * no-mistakes(review): Harden merge authority persistence against lifecycle races * no-mistakes(review): Serialize poll authority publication with teardown * no-mistakes(document): Clarify persisted merge authority lifecycle * no-mistakes(ci): Added targeted SC2034 suppressions for the two public result assignments in bin/fm-merge-authority-lib.sh. Verified successfully with `CI=true bin/fm-lint.sh` * ci: supersede superseded PR CI and bound unbounded jobs (#4281) The 2026-09-12 Actions starvation incident found firstmate CI with no concurrency deduplication, so every superseded PR head kept its full 13-job fan-out, and four jobs with no timeout at all. Add per-PR supersession keyed on the PR number for pull_request events and on the unique run id for push events, cancelling only pull_request runs, so a new PR head replaces its own in-flight CI while every main push keeps its own group and is never cancelled. Add hang tripwires to the four previously unbounded jobs: 25 minutes for lint (measured at 14-16 minutes) and 5 minutes each for the coverage guard, the timing aggregate, and the repo invariants. Measured lane bounds are unchanged. tests/fm-ci-workflow.test.sh resolves the workflow's concurrency expressions against simulated pull_request and push contexts and holds every job's finite timeout. * test(watch): gate backlog-hold away-record fixture on tasks-axi (#4288) Every other make_hold_home caller in this file skips when tasks-axi is absent; this test was the one unguarded call, so hosts without tasks-axi hard-fail the fixture build instead of skipping. * fix(backlog): bound per-item backlog row reads so a wedged backend cannot blind a session start (#4027) * fix(bin): bound each backlog row read so one wedged backend cannot blind a session start bin/fm-bootstrap.sh's reconcile and close-replay sweeps read the backlog backend once per item through fm_backlog_row_show, and that read was unbounded. A single wedged `tasks-axi show` therefore consumed the whole FM_SESSION_START_TIMEOUT and truncated the digest before the wake queue, supervision instructions, fleet state, and context sections ever printed, leaving the fleet unsupervised with no live watcher. The harm was a blind startup, not a slow one. Bound the read with the existing shared timeout primitive (bin/fm-timeout-lib.sh), so a wedged backend degrades to a loud partial reconcile: the sweep's existing BACKLOG_RECONCILE diagnostic names the item it could not read and the loop continues to the next one. The first bound hit also latches FM_BACKLOG_ROW_SHOW_WEDGED, so a sweep over many items pays one bound rather than one per item and still names every item it skipped, which is what keeps the digest whole on a home carrying a large fleet. The bound holds regardless of any particular tasks-axi install, so it does not depend on the 0.2.5 `show` hang being resolved separately. * fix(bin): set the wedged-backend latch where it survives, and prove it The latch added with the read bound was inert. fm_backlog_row_show runs inside a command substitution in both of its status-capturing callers, so the subshell read the inherited value correctly but its write died with the subshell. Every item still paid a full bound and reported `exceeded`, never `skipped`, which left the large-fleet case the latch existed to cover completely uncovered. Move the write to the two callers that capture the read's status and own the surviving shell, and leave fm_backlog_row_show reading the latch only. Correct the comments that claimed an ownership the function never had. The test that was supposed to cover this asserted only that the second read finished under a generous ceiling, which is true whether or not the latch works. Assert instead that a latched read is strictly faster than one bound and that it reports its own item as skipped, so an inert latch fails the test. * test: cover every item the wedged-backend latch skips The latch assertion exercised a single skipped item, so "every skipped item is still named" was inferred rather than tested. Probe three items instead and assert each skipped one names itself and costs less than a bound. Verified as a real guard by removing both latch writes: the suite then fails on the first skipped item instead of passing. * no-mistakes(review): distinguish backlog read-bound hits from absent rows * no-mistakes(review): preserve read-bound status through the captain verify gates * no-mistakes(review): Preserve backlog read-bound hits through resolve_entry and reconcile instead of spending them as absent rows * no-mistakes(review): Preserve backlog read-bound 124 through migrated-prefix scan and remaining task_show call sites * no-mistakes(document): Document bounded backlog row reads and FM_BACKLOG_ROW_TIMEOUT_SECS * no-mistakes(ci): Fixed all four failing CI checks with one root-cause fix plus one test-heredity fix. (1) bin/fm-captain-hold.sh: task_show carries the row in TASK_SHOW_OUTPUT and emits no stdout, but four call sites still used the stale command-substitution convention show=$(task_show ...), leaving show empty: task_show_or_fail (every captain hold failed with 'did not retain its hold-set stamp' - broke fm-captain-hold-lifecycle in parallel 1 and fm-bearings-board in serial 3), resolve_migrated_entry (migrated-prefix resolution could never match), reconcile-requests (existing rows were refused as absent), and command_open --identity (printed a constant '#0' identity, so fm-watch-triage's re-held captain call inherited the previous call's silence in serial 1). This is also the Greptile P1. Fixed by invoking task_show in the current shell and reading show=$TASK_SHOW_OUTPUT, the convention the other eight call sites already use; read-bound hits still stop loudly by name. (2) tests/fm-backlog-read-bound.test.sh (serial 4, unclassified family): the new e2e half implicitly relied on the author's process tree containing a harness process so fm-lock.sh would grant the fleet lock; on CI runners the lock is refused, the reconcile sweep is skipped, and the final BACKLOG_RECONCILE assertion fails. Reproduced by simulating a CI ancestry via a ps shim, fixed by pinning the lock evidence with the established fake-ps harness fixture pattern from tests/fm-session-start.test.sh. Verified: shellcheck clean; parallel-1, serial-3, and serial-4 lanes fully green locally (failed=0); serial-1 lane green except fm-gemini-harness, which fails only under local Node v26 (comm=node-MainThread); CI's default Node 22 reports comm=node, the branch that test passes on, so it is not a CI failure * no-mistakes(document): Verified bounded backlog read docs accurate across branch * fix(merge): serialize away authority with synchronous merges (#4285) * fix(merge): serialize the away-authority check with a synchronous merge bin/fm-pr-merge.sh read the away-posture record for merge authority (the per-task merge grant and the yolo/away-grant decision) and handed the merge to the forge afterwards. An archive at the captain's return or a grant revoked by a replacement record could land in between, so a merge could proceed on away authority that no longer held. The away record now carries a cross-subsystem lock, built on the existing bounded lock primitive rather than a new lock format: the record-mutating subcommands hold it across their mutation, and the merge holds it across both its authority read and the forge command. Because a queued or auto merge returns before the pull request lands, and would therefore outlive the lock, an away merge is now refused whenever it could land asynchronously: a requested --auto, a base branch whose merge-queue state does not prove an immediate merge, and GitLab's asynchronous flags and configuration. What remains permitted while away is the synchronous merge that lands inside the lock. This closes the common away-record/merge race against a live lock owner. It does not make the merge atomic in every case, and two narrow races are accepted and documented at their sites rather than hidden, both confused-agent-grade in the sense bin/fm-lease-lib.sh already uses: - A merge-queue rule change or a PR base change in the window between the queue-free preflight and the forge call can still enqueue the merge, which can then land after its grant lapses. - Killing the lock-owning shell while its gh or glab child is still running lets stale-owner recovery reclaim the lock and the record be archived or replaced, after which the orphaned child can complete the merge on lapsed authority. Closing either one needs landing verification or an ownership handoff, which is deliberately out of scope here. No existing gate is relaxed. The lock is taken after the live green-at-head verify and the captain-hold check, the in-lock authority read is unchanged, and a lock that cannot be taken refuses the merge rather than proceeding unlocked. The away grant stays a structured field; no prose is parsed. * no-mistakes(review): Fix GitHub rollup fixture base branch * no-mistakes(document): Document atomic away-authority merge locking * no-mistakes(ci): Updated two executable GitHub API fixtures to include the required baseRefName. Both previously failing test suites now pass: fm-captain-hold-lifecycle.test.sh and fm-pr-check-security.test.sh. git diff --check also passes * feat(bin): add Antigravity CLI (agy) as third worker/scout adapter (#4200) * feat(agy): verify Antigravity CLI as third worker/scout adapter Detection by anchored ancestry in fm-harness.sh (no marker of its own); bootstrap harness and effort validation; launch template with model and effort mapping plus reachable-catalog model validation; rendered-tail busy fallback in fm-busy-lib.sh with delivery footer in fm-composer-lib.sh; control mechanics with crewmate/scout-only refusal; tmux liveness naming; router entry with concise adapter reference; dated verification record; portable regression plus opt-in live drift guard. Verified live on agy 1.2.0: supervised spawn, durable steering, same-copy relaunch, and exit, with Herdr-native busy agreement. * no-mistakes(review): bound agy model probe, gate trust dialog, narrow busy signature * no-mistakes(review): pre-register agy workspace trust, make readiness gate strict * no-mistakes(review): Close Orca terminal on gate failure; isolate live-guard HOME; tighten agy matching * no-mistakes(document): Document agy adapter in stale harness enumerations * no-mistakes(review): Clamp non-positive FM_AGY_MODELS_TIMEOUT to the default bound * no-mistakes(document): Fix stale test-shard snapshots after agy lane additions * no-mistakes(ci): Fixed ci-3 (tests/fm-agy-harness.test.sh:519). Root cause: the agy spawn fixture's default base PATH (/usr/bin:/bin:/usr/sbin:/sbin) omits node's directory, but the spawn drives the real bin/fm-agy-trust.sh (which hard-requires node to record trust) and the fixture's fake tmux trust lookup (node -e) under that PATH. On the ubuntu-latest CI runner node lives in the toolcache (/usr/local/bin), so trust pre-registration failed on portable serial 2; on typical Arch hosts node is in /usr/bin, masking the defect. Fix (smallest, following the existing tests/fm-kimi-harness.test.sh precedent of carrying the interpreter's resolved directory): resolve node from the invoking environment (failing the test with 'test needs node' if absent, as kimi does f…
Mauryanx
added a commit
to Mauryanx/firstmate
that referenced
this pull request
Sep 18, 2026
… through typed dispatch (#9) * fix(bin): rebalance portable parallel test lanes using CI timings (#4151) * ci: rebalance the portable parallel lanes on measured runner durations Both portable parallel lanes are capped at 10 minutes. Lane 1 was cancelled at that cap on every request raised on 2026-09-10 while lane 2 finished in about 3.5 minutes, so no request could go green. CONTRACT CLASS: RESTORE. The workflow already promises two duration-balanced lanes and the shard documentation already claims a measured wall; this re-establishes both against what the lanes now cost, and changes no lane count, no cap, and no scope of what runs. The counter-argument, so nobody has to take that on trust: two pieces here are genuinely new rather than restored, and either could be argued to make this a NEW-behavior change. `--list-scheduled` now ranks a parallel lane on measured durations where it previously handed every parallel script the serial default weight and returned an alphabetical order; and `--check-coverage` gains three reported fields. I classify the change RESTORE because both exist only to make the already-promised property checkable, but they are named here rather than folded into the restoration. === PART 1: THE TOTAL, AND HOW IT WAS OBTAINED === This section stands on its own. It establishes what the parallel set costs. It derives no packing; Part 2 does that, from this number. THE TOTAL: 828568 ms, about 13 min 49 s of serial work across the 24 scripts. Lane 1 held 624299 ms of it and lane 2 held 204269 ms, a 3.06:1 split. HOW IT WAS OBTAINED. The difficulty was that lane 1 had never finished, so its duration did not exist as a recorded figure anywhere and no timing artifact was expected for it. It turned out to be recoverable from the real lane without estimating, by two routes, across six CI runs on 2026-09-10 (34459949083, 34460760299, 34462530836, 34462758357, 34466966385, 34470382458): - Run 34462758357's lane-1 job finished its suite 18 s BEFORE the wall and uploaded a complete fm-test-timing-portable-parallel-1 artifact carrying all 11 scripts, FM_TEST_SUMMARY total=11 failed=0 duration_ms=598225. The upload step is if: always(), so the cancellation did not suppress it. This is one full, untruncated lane-1 measurement. - The five other lane-1 jobs were cancelled mid-suite, but each logs every script that had already finished as an FM_TEST_END duration_ms= marker. Those per-script records are complete measurements of completed scripts; only the script in flight at cancellation is lost, and it differs by run. Lane 2 completed in all six runs, so its scripts come from the six uploaded fm-test-timing-portable-parallel-2 artifacts. Every one of the 24 scripts therefore carries at least one untruncated measurement: 20 of them measured in all six runs, two in three or four runs, and two (fm-brief, fm-transition-lib, the tail of lane 1) in the single complete run. Each hint is the SLOWEST value that script reached, so the total is an upper envelope rather than an average. NO FIGURE IN IT IS DERIVED FROM A TRUNCATED LANE, and no lower bound was ever extrapolated into a total. THE ENVIRONMENT, AND WHETHER IT TRANSFERS. Every hint is a serial run of the real portable parallel lane on a GitHub ubuntu-latest runner, produced by the lane's own CI job. It transfers because it is not a proxy for the lane; it is the lane. Nothing in the total came from this machine or from any harness of mine. That mattered, and here is what it would have cost. A same-day macOS cross-check of the same scripts ran 1.7x to 5.0x slower with the ratio varying per script (fm-test-run 157420 ms against 92944 ms, fm-x-mode 67217 ms against 31870 ms, fm-composer-ghost 10521 ms against 2120 ms). Local timings therefore do not scale the lane, they REORDER it, so a packing derived from them would have balanced the wrong thing while looking clean. WHAT IT REPLACES, which is the root cause. The lanes were packed from the 2026-08-20 concurrent isolation proof: 24 candidates across four LOCAL workers. That record answers whether the candidates are isolation-safe, not how long a SERIAL CI lane runs, so it was structurally incapable of representing lane wall clock even when it was fresh. It was also never refreshed while the set grew about 3.2x. Both the wrong instrument and the staleness are fixed here: the hints now come from the lane itself and carry their run ids and date. === PART 2: THE SPLIT DERIVED FROM THAT TOTAL === Longest-processing-time assignment over those hints gives 414269 ms and 414299 ms, 30 ms apart, against 624299/204269 before. tests/fm-pi-primary-types.test.sh stays in lane 1 because that is the job which installs the Pi package, so ci.yml needs no step changes. === PART 3: DOES THE MARGIN SURVIVE MACHINE VARIANCE === Stated explicitly, because 6.90 min against a 10 min cap is 69% of cap before any variance is applied, and the cap covers the whole job rather than the suite. worst lane, script time 414299 ms 6.90 min job overhead, measured on the real lane ~18 s (see below) expected healthy job ~432300 ms 7.21 min x1.29 on the script time, plus overhead ~552400 ms 9.21 min cap 600000 ms 10.00 min room left after the multiplication ~47.6 s 7.9% of cap The 1.29x is the runner variance measured today on the SIBLING SERIAL lane, as supplied; it is not this lane's own figure. This lane family does have its own, and it is tighter: the six full lane-2 sums today span 192939 ms to 203451 ms, a spread of 1.054x. At that figure the worst lane lands near 7.58 min with about 2.4 min of room. I have used the LARGER, borrowed 1.29x for the verdict rather than the tighter one this lane actually shows, and note that the hints are already per-script maxima, so 1.29x on top is conservative twice over. THE MARGIN SURVIVES THE MULTIPLICATION, so this proceeds rather than stopping. The 18 s overhead is measured, not assumed: in run 34462758357 the lane-1 job ran 10 min 16 s against a 598.2 s suite, and lane 2 ran 3 min 21 s against a 192.9 s suite, a ~10 s difference that matches lane 1's extra Pi package install. The cap is unchanged, the lane count is unchanged, and nothing in the serial lane, its shard count, its guard or its hint table is touched. === PART 4: THE RECORDED FACT === The workflow comment no longer restates the shard wall as a literal, which is how "~1 min of serial sum" survived a 10x change without announcing it. It now points at bin/fm-test-run.sh --check-coverage, which prints parallel_max_ms, parallel_imbalance_ms and parallel_unhinted derived from the hint table, so the current number is computed on demand. The shard documentation carries the dated run ids, which route it was taken by, and the local cross-check that shows why local numbers are not admissible as hints. Two regressions pin what rotted: lane membership must be stored longest-measured-first, and the lanes must be fully hinted and packed within 5% of each other. Both were run against the old composition and both fail on it (420030 ms imbalance against a 624299 ms worst lane). The ordering assertion they replace named a specific script by hand and had itself gone stale. === PART 5: NAMED AND LEFT, OUTSIDE THIS REBALANCE === tests/fm-captain-hold-lifecycle.test.sh alone is 296481 ms, 36% of the whole set, so it is the floor of any two-lane split: no repacking can put a lane below it. After this rebalance the cap is about 1.45x the healthy lane where the sibling serial lane keeps roughly 2x. Nothing refuses a stale parallel hint the way PORTABLE_SERIAL_MAX_UNHINTED_PERCENT bounds the serial lane. parallel_unhinted is reported, not enforced, which is what let this drift for three weeks unnoticed. * fix(review): Restrict parallel scheduling hints to portable parallel lanes * fix(document): Clarify parallel lane scheduling and timing evidence * fix(bin): stop claiming prose-mentioned PR URLs as a task's delivered PR (#4148) pr_for_task fell back to scraping the whole status log with tail -1, so any PR URL a worker ever mentioned in prose - including a scout citing someone else's PR - became the task's delivered PR in the parent-channel terminal report. Recorded meta pr= is now the only authoritative source, the fallback scrape accepts only a preferred terminal line in a mode's ready-signal shape (done: PR <url> or done: PR <url> checks green), and a scout never carries pr= at all. * fix(procevent): confirm reconcile launches and reclaim provably dead claims instead of counting a dead drop as started (#4212) * fix(procevent): stop a dead runner owning a source and reconcile reporting it The captain answered ten calls on a bearings board, the board accepted them, and nothing collected them. He had to answer all ten again in chat. A surface that presents as armed while being a dead drop is worse than one that visibly fails, because the answers looked recorded. Two independent defects, reproduced together in an isolated home where reconcile reports started=1 on every run while ownership never moves and no runner ever attaches. 1. reconcile counted a launch it never verified. detach_runner is fire-and-forget and discards the child's stderr, so a runner that died before it could claim was counted exactly like one that is listening. Launches are now confirmed - the source observed owned, or its runner record moved - before being reported as started; the rest are reported as failed= with a non-zero exit. The runner-record clause is what keeps a fast-completing source from being reported as a failure when it finished between two polls. One bounded window covers a whole cycle's launches, so a home full of broken sources costs the same wait as one. 2. A claim whose whole generation is provably gone could be refused forever. Reclaiming it ran cleanups over that dead generation's own leftovers, and any failure vetoed the claim - permanently, because none of those conditions clears on its own. Every one of those leftovers is keyed by the dead generation's claim token and a replacement always claims a fresh one, so none can collide with what replaces it. fm_procevent_claim_capture_reservation_reclaim_locked already said this for the reservation record; the staging file and the shape check on the registry directory recorded to hold it now take the same rule. Removing the claim record itself stays a hard precondition: two owners is the one outcome worse than none. Two smaller repairs to the same "registered is not listening" confusion: - `list` reported OWNER=none for a source nothing can claim. A reused PID whose process group survives reaches that state through the stale branch rather than the leaderless one, so it read as an idle source waiting to be started - the reassuring answer this surface gave while a board collected nothing. It now reports the orphaned state it shares. - reconcile relaunched into that same unclaimable state on every cycle, spawning a runner that could only die on the claim. docs/configuration.md already promised it preserves such a claim without starting a replacement; the code now does that and reports it as uncertain. This is NOT a third instance of today's two lock-identity defects (4e1bf9aa and its replayed predecessor). Those were wrong liveness predicates: a reused PID read as a live holder, then an exec'd holder read as dead. Here the predicate is right - the code correctly proves the owner dead and refuses the claim anyway, on a condition unrelated to liveness. Regression coverage, each failing on the parent commit for its own reason: - tests/fm-procevent.test.sh: a source that cannot start is reported as failed rather than started; a dead generation whose leftovers cannot be tidied no longer keeps owning its source (the parent reports a start while nothing ever runs); the existing reused-PID fixture now also asserts the orphaned listing and that no doomed relaunch is reported. - tests/fm-captain-hold-lifecycle.test.sh: a board answer reaches the keyed-answer intake through the runner end to end - durable capture, the wake, and the closed task carrying the captain's selection. This one passes on the parent, because that chain was never what broke. fm-procevent 100, fm-bearings-board 18, fm-captain-hold-lifecycle 50, fm-procevent-when 13 and fm-procevent-quota 18 pass; bin/fm-lint.sh and bin/fm-doc-audience-check.sh clean. tests/fm-extension-binding.test.sh has two failures identical on the parent commit (EACCES on package install in this sandbox) and unrelated to this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gxgshn5jkWJ3GEYWy7vTG * no-mistakes(review): confirm reconcile launches on durable launch stamps * no-mistakes(review): announce stranded sources and refuse bad confirm windows * no-mistakes(review): announce leaderless strands, bound confirm window, fix recovery docs * no-mistakes(review): announce unconfirmed launches once per episode, qualify start reclaim * no-mistakes(review): nonce launch-failed keys, refuse bad window at arm * no-mistakes(review): state only observed launch outcome, shorten episode nonce * no-mistakes(test): assert launch-failed headline not re-delivered, allow recovery wake * no-mistakes(document): docs: cover strand and launch-failure wakes in skill trigger and verification record * no-mistakes(lint): restructure SC2015 chain into explicit if-block * test(watch-triage): fix two timing-exposed defects the pipeline found Both surfaced in the no-mistakes test step on this branch, each failing one full run of tests/fm-watch-triage.test.sh; neither was accepted as a flake to retry past. 1. The new launch-failed delivery test assumed an already-surfaced key never wakes the watcher again. That is false: a fresh watcher legitimately re-surfaces any unacknowledged queue row through its downtime-recovery path ("check: rearm-resurface"), so the assertion failed whenever a re-arm landed between its two checks. The pipeline's own fix tolerated any wake lacking the repeated key's headline; this tightens it to exactly one tolerated reason, by its exact line, with a failure message that names the expectation so a reworded path reads as "the tolerated recovery path changed" rather than as a mystery - and so nobody restores the strict silence check. The positive assertion (a fresh-suffix key is delivered under its own headline) is unchanged. 2. seed_captured_procevent_result retired its source in the gap between the runner publishing its wake and releasing its claim, so retire read the exiting runner's ownership as uncertain and refused ("cannot confirm runner identity"). The fixture and retire path pre-date this branch; the confirm window returns reconcile closer to the moment of capture, which made the gap easier to hit. The fixture now waits, bounded, for the claim release the publish promises, with the reason at the wait. Verified on this head with tasks-axi on PATH: fm-watch-triage 113/113 with no skips, fm-procevent 106/106, fm-captain-hold-lifecycle 50/50, fm-watch-arm 15/15, fm-bearings-board 18/18, fm-procevent-when 13/13, fm-procevent-quota 18/18; bin/fm-lint.sh and bin/fm-doc-audience-check.sh exit 0. First attempt, no retries. * no-mistakes(document): docs: route stranded and launch-failed wakes in skill handling --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(herdr): verify agent liveness at process level before trusting registration (#4191) * fix(herdr): verify agent registrations at process level before trusting them Herdr keeps a Pi registration (`agent get` -> agent=pi, agent_status=idle) after the Pi process has exited to a plain shell whenever a nested interactive shell sits under the pane's top shell, which is the crew shape `treehouse get` leaves behind. The pane classifier trusted that registration alone, so `fm-control.sh <id> relaunch`, `fm-spawn.sh --relaunch`, and the crew-state recovery read all treated a shell-only pane as a live agent and refused recovery for as long as the record lived. The Herdr adapter now reads `pane process-info` plus the real process table through a shared harness-process classifier (bin/fm-agent-process-lib.sh, moved verbatim out of the tmux adapter so both backends mean the same thing by agent, shell, and other) before a registered agent counts as live. A registration over a shell-only pane is the new explicit `stale-agent` pane state, which the recovery-grade read maps to `dead`; husk detection, reclaim, presentation recovery, and session cleanup keep refusing it, so recovery reuses the pane and nothing gains close authority. A working record is verified the same way before the native busy verdict reports busy, so the recovery classifier never reports a shell-only pane as working. An unreadable process view reads unknown, trusting neither the registration nor its absence. Reproduced and measured on Herdr 0.9.0 with Pi 0.85.1 in an isolated lab; the new default-on live guard tests/fm-herdr-pi-stale-registration-live-e2e.test.sh exercises the real stale record, tests/fm-control-herdr-smoke.test.sh proves exit and relaunch through the control plane, and the portable suites pin the classifier over real processes. Fixes #4115. Duplicates: #3639, #3487, #2908, #3545. * no-mistakes(review): settle transient prompt helpers before trusting herdr process state * no-mistakes(review): drop stray codegraph file; read spaced comm whole in descendant walk * no-mistakes(review): untrack stray .codegraph/.gitignore * no-mistakes(review): untrack codegraph file; make spaced-path walk test discriminating * no-mistakes(review): untrack stray .codegraph/.gitignore * no-mistakes(review): untrack stray .codegraph/.gitignore re-added by fix round * no-mistakes(review): untrack stray .codegraph/.gitignore * no-mistakes(review): untrack codegraph file, drop dead control case, record process-info floor * no-mistakes(review): refuse stale-agent on fresh herdr spawn preflight Documented non-goal: fresh-spawn, reclaim, and presentation-recovery auto-recovery for a stale-agent pane is a separate design change, out of scope here, to be proposed upstream as its own issue if wanted. * no-mistakes(test): Fix herdr flake: don't misread transient empty foreground as unreadable * no-mistakes(document): Add fm-agent-process-lib.sh to scripts inventory * no-mistakes(fix): update remote herdr fixture to the real pane process-info shape The shared remote-secondmate herdr fixture still returned the old flat process-info body ({"result":{"process":{"name":...}}}). The process-level liveness classifier added for #4115 requires the real {"result":{"type":"pane_process_info","process_info":{...foreground_processes}}} shape and treated the old body as unreadable, so an already-launched remote endpoint's agent-state read failed and any relaunch attempt against it died with "remote endpoint state is unreadable; refusing duplicate launch" instead of reaching the state it was actually exercising (tests/fm-remote-secondmate-parent-binding.test.sh, tests/fm-remote-secondmate-lifecycle-e2e.test.sh). * no-mistakes(review): test: add empty-foreground regression test for herdr flake fix * no-mistakes(document): docs: register new stale-registration live-e2e test in herdr entry points * feat: add live-head merge gates and away task grants (#4199) * Bind GitHub merges to a live green head and require an away-task grant. A GitHub merge now re-reads the pull request and passes --match-head-commit, so a red or moved head cannot land the way GitLab already refused. While an away record exists, only yolo or a named grant may merge, so hold-for-return cannot ship an ungated PR. Co-authored-by: Cursor <cursoragent@cursor.com> * no-mistakes(review): Harden away merge authorization and grant parsing * no-mistakes(review): Restrict fallback outcomes to proved GitHub merges * no-mistakes(document): Refresh merge safety documentation * no-mistakes(ci): Fixed all three CI failures by updating legacy GitHub merge fixtures for live-head verification/direct gh merges and removing a process-event runner cleanup race. Verified fm-pr-check-security, fm-captain-hold-lifecycle, and fm-watch-triage pass locally; shell syntax and git diff checks also pass * no-mistakes(document): Document attended red-check exception --------- Co-authored-by: Cursor <cursoragent@cursor.com> * fix(bin): bound the Claude turn-end re-block against a frozen auto-arm epoch (#4221) The --claude guard's re-block budget charged the auto-arm ledger epoch, not the re-block: `budget_account_current_epoch` advanced the session count only when `state/.claude-autoarm-epoch` named a different generation than the previous accounting. The epoch advances only inside the auto-arm hook's generation claim, so a hook kept inert before that claim - a session lock held by a live harness outside its ancestry, a hook that never fires, or an identity or write failure ahead of `fm_autoarm_claim_next` - left the ledger frozen at its last outcome and the count frozen with it. Reproduced in a fixture: twelve consecutive Stops re-blocked with the count at 0 and the attended fail-open never fired, leaving only Claude's silent 8-block override, the blind end the bounded alarm exists to prevent. The budget now charges a re-block against an epoch the previous re-block already charged, while still charging each epoch at most once per Stop so the wait loop's repeated observations of one fresh terminal outcome and the same invocation's block decision cannot double count. The advancing-epoch progression is unchanged: three re-blocks, then one attended fail-open for a verified failure episode, and a frozen epoch now follows the same shape. Budget exhaustion without a verified failure still blocks, by the existing contract, and positive watcher recovery still clears the whole episode. Regression coverage drives the real auto-arm hook against a foreign session lock holder, asserts the ledger itself stays frozen, and fails before the fix in both the verified and unverified shapes; the existing unverified budget test now proves its budget actually ran out. * feat(herdr): add guarded foreground viewer for live validation (#4242) * feat(herdr): attach a real foreground viewer so the live-client teardown cases can be driven PR #4131 gated the Herdr active-tab close refusal on a live foreground client instead of the persisted `.focused` pointer, but only its two detached scenarios could be validated live. Every pseudo-terminal the runner built started at a zero-sized window grid, so Herdr registered no foreground client and `terminal title clear` kept answering `no_foreground_client`, leaving the four attached-client scenarios untested. That was a harness limit, not a product one. Add `fm-herdr-lab.sh viewer start|stop <session>`, backed by `bin/fm-herdr-lab-viewer.py`. The launcher sets the pty window size on the master fd BEFORE the fork, so the TUI cannot read the grid until it is already non-zero, and scrubs the inherited `HERDR_*` variables so Herdr's nested-viewer refusal does not fire when the helper runs inside one of its own panes. Attach and detach are both confirmed against the session's own foreground-client reason rather than assumed from a signal. The viewer inherits the lab's isolation contract: it attaches only to a session carrying this lab's ownership tripwire, never to `default`, and it signals only the processes it recorded, so a client someone else attached is never touched. Teardown now refuses while an owned viewer is still attached. Turn the reproduction into the regression with `tests/fm-herdr-attached-viewer-live-e2e.test.sh`, which drives #4131's scenarios 3, 4, 5, and 7 live against real Herdr and asserts the close refusal fires. Scenarios 4 and 5 need a focus change at one exact product boundary, so a PATH shim performs the real `tab focus` when the close helper issues its planning `pane get`. Removing either half of the recipe from the launcher makes the guard fail with the same `no_foreground_client` symptom #4131 reported. * test(herdr): fail loudly when an attached-viewer fixture cannot be created The fixture helpers run inside command substitutions, where fail() exits only the subshell and leaves the script running with empty ids. Return non-zero instead and carry the message at each call site. * fix(herdr): stop the viewer launcher's kill timer from raising on an exited child The SIGALRM escalation called os.kill unguarded, so a viewer that exited during the grace window turned an ordinary shutdown into a traceback inside the signal handler. * docs: list the lab viewer's pty engine in the bin toolbelt * no-mistakes(review): Harden Herdr viewer ownership and live CI coverage * no-mistakes(review): Validate viewer startup timeout and process ownership * no-mistakes(document): Document Herdr viewer safety contracts * no-mistakes(review): Fix viewer timeout to two seconds * no-mistakes(review): Cancel timed-out viewers and fix PTY grid * no-mistakes(review): Serialize viewer transitions and verify process parentage * no-mistakes(review): Harden viewer ownership locks and deduplicate CI * no-mistakes(review): Release interrupted locks and preserve viewer escalation * no-mistakes(review): Remove viewer locks and cancel interrupted launches * no-mistakes(review): Close viewer launch signal races * no-mistakes(document): Document attached Herdr viewer regression * fix(bin): let nonvisual work proceed when lavish-axi is unavailable (#3766) * fix(bootstrap): allow nonvisual work without Lavish * no-mistakes(review): Gate scout brief Lavish line on bootstrap version floor * test: isolate fixture Git config from host global and system settings (#3825) * fix(tests): isolate fixture Git configuration from host preferences Ignore global and system Git configuration in the shared test library, which all four fixture helper entry points source. Keep local config, command-line overrides and explicitly supplied test config usable without changing the caller's environment or real project signing preferences. Exercise global and system signing inputs through all four helpers, real fixture and child commits, explicit signing overrides, unchanged input files, and signing refusal outside fixture subprocesses. Verification evidence for issue #3770: On pristine upstream f09de8a3, all 12 reported suites failed and each logged "No secret key" using a private GIT_CONFIG_GLOBAL containing commit.gpgsign=true and gpg.format=openpgp, GIT_CONFIG_NOSYSTEM=1, and an empty private GNUPGHOME (GIT_CONFIG_COUNT and GIT_CONFIG_PARAMETERS unset). With this change, all 12 pass in the identical environment through bin/fm-test-run.sh --per-script-timeout-secs 900: fm-backlog-atomicity, fm-bootstrap-network-parallel, fm-bootstrap, fm-crew-state, fm-fleet-sync, fm-gate-refuse, fm-grok-harness, fm-session-start, fm-sessionstart-nudge, fm-tangle-guard, fm-test-run, and fm-update (all tests/<name>.test.sh). The new fm-test-fixtures regression failed before the library change and passes after it. Canonical bin/fm-lint.sh passes. Additional verification exposed fm-teardown's herdr-preflight-missing-adapter assertion on both this branch and an unchanged f09de8a3 archive with signing neutralized. That pre-existing failure needs separate disposition; it is not repaired or skipped here. The separately owned Muse and composer fixture defects remain untouched. Fixes #3770 * no-mistakes(review): Complete fixture Git isolation and scope config assertions * no-mistakes(review): Share Git isolation across standalone fixture entry points * no-mistakes(review): Map git-config helper changes to lib.sh dependents * no-mistakes(review): Select fixture-isolation regression on runner change; halve config matrix * no-mistakes(review): Scope fixture-isolation regression selection to the runner alone * no-mistakes(document): Give fixture Git isolation helper its owning header * no-mistakes(document): Record fixture Git-isolation coverage in fixtures suite header * no-mistakes(review): Fix linked-worktree fixtures and remove redundant Git isolation * no-mistakes(document): Correct stale runner-selection documentation * no-mistakes(document): Clarify family antecedent in isolation-proof runner evidence * feat(bin): add config/claude-permission-mode to launch Claude workers in auto mode (#4239) * feat(spawn): add config/claude-permission-mode to launch Claude workers in auto mode Every Claude worker launched with --dangerously-skip-permissions, and a captain who refuses bypass mode had no way to select Claude Code's classifier-reviewed auto mode instead. A new one-token local config, config/claude-permission-mode, selects the permission flag for every Claude launch: absent or `bypass` keeps today's launch byte-for-byte, `auto` swaps in --permission-mode auto, and any other value refuses the spawn before any endpoint, worktree, or record exists and names the accepted values. fm-spawn resolves the file on every spawn and relaunch, threads the flag through the Claude launch template for crewmates, scouts, and secondmates alike, and records claude_permission_mode=auto in the task meta only under auto so the default meta stays unchanged; a relaunch re-resolves rather than preserving the line. The file is a captain-wide safety preference, so it joins the inherited local material pushed into secondmate homes. The Claude adapter reference records the verified auto launch shape on Claude Code 2.1.269 and that it never meets the once-per-machine bypass confirmation dialog; docs/configuration.md owns the schema. * no-mistakes(review): drop unread claude_permission_mode meta line and its assertions * fix(teardown): leave a Treehouse pool slot reassigned to another task untouched (#4243) * fix(teardown): refuse to return a Treehouse pool slot reassigned to another task A pool slot is reused across tasks, so a finished task's worktree= line can name a slot a different, live task now holds. Teardown already refused when a second task record named the same live path, but that scan cannot prove the record it is tearing down is the current owner: the task that took the slot next may leave no record the scan can reach - its own worker may have exited and its record been cleaned up, or it may live in a home this machine does not register. Teardown then killed every process under the path, hard-reset it and returned it, and its unlanded-work refusal never fired because it was inspecting a directory that no longer belonged to the task being torn down (observed 2026-09-07). Treehouse's own state file cannot answer the ownership question. It records a slot's owner as a live process lease (owner_pid plus owner_started_at, with `treehouse status` reporting in-use from the processes actually running under the path), which names no task and is released by the very event that makes a record stale - the worker exiting. An unleased slot therefore reads identical whether it is still this task's or has since been handed on, and a slot whose new holder has also exited but left uncommitted work reads as free. So the identity source is Firstmate's own claim, not Treehouse's lease. fm-spawn writes that claim - the task id - into the slot at the moment it takes it, under the same project lock that allocates the slot, and fm-teardown drops it only after the slot is genuinely returned. It lives at <pool>/<slot>/.fm-slot-owner, a sibling of the repo checkout rather than a file inside it, so claiming a slot can never dirty the copy the landed-work checks inspect. A claim naming another task, or one that cannot be read, refuses; --force does not lift either refusal, because --force authorizes discarding this task's unlanded work, never another task's live work. A slot that cannot be claimed refuses the spawn instead. An absent claim proceeds on exactly the record-scan protection it had before: slots taken before claims existed, and slots already returned, carry none, and refusing those would strand every task in flight across this change on no evidence at all. The refusal is deliberately all-or-nothing rather than partially completing the task's own cleanup. state/<id>.meta is the only durable record naming the worktree and endpoint, so removing it would destroy the evidence needed to reconcile which record is wrong, and its removal is one step with the backlog transition. Nothing is stranded: clearing the stale worktree= line leaves a record with no slot to release, which then tears down normally, and the refusal names that remedy. Repairing the previous claimant's stale worktree= line at spawn time is left for separate work. It would have the new owner write another task's record - the same class of cross-task mutation this bug is - and would need that record's own meta lock; with the claim in place teardown refuses on evidence rather than depending on the stale pointer having been scrubbed. For the same reason the relaunch path writes no claim: it holds no allocation lock, and a record whose worktree= is already stale would stamp the wrong task's claim onto a live sibling's slot. The regression reproduces the reuse sequence with only one discoverable record, including a clean, fully landed ship copy torn down without --force - the shape of the real incident, which the previous code returned to the pool - and fails against the previous code; the existing two-record, cross-home, own-slot and no-claim cases still pass unchanged. This builds ON upstream b028e8b1 (#3837), which is already in this branch's base (origin/main 40c50ea8) and owns the record-exclusivity scan. Nothing here replaces that scan; the claim is the positive proof it cannot supply. Claude-Session: https://claude.ai/code/session_01JTBmuqKugaPUj7k9TXQwFS * no-mistakes(review): teardown leaves reassigned slot; spawn abort drops claim * no-mistakes(review): narrow Treehouse lease evidence; gate abort claim release on lock * no-mistakes(review): pin spawn-side slot claim; narrow abort-release header * no-mistakes(document): docs: point slot-claim rationale at fm-wake-lib owner * docs(AGENTS): keep brief-fill from widening the captain's ask (#4247) The reviewer treats Captain's intent as acceptance criteria, so a widened ask there drives over-built work; the spec should carry only what the ask requires. * fix: identify underway tasks and sort charted work (#4245) * feat(bearings): name the Underway rows and order Charted Next newest filed first The fleet board's Underway rows led with the run status alone, so a scan told the captain where a pipeline stood but never which task the row was, and Charted Next rendered in backlog order rather than by when work was filed. The snapshot now projects the durable task name onto every in_flight row - from this home's backlog title, and from a secondmate home's own ledger for an active child - and the durable filed date onto every gate. The board's Underway row leads with that name and keeps the run status on its second line, and Charted Next renders newest filed first, with rows carrying no comparable date keeping their payload order after every dated row. The payload validator requires an explicit name marker on every Underway row and refuses a filed value that is not an ISO date, so the board can never sort on garbage or invent a label. * no-mistakes(review): Fix Bearings labels, bounds, and filed validation * no-mistakes(review): Fix Bearings identifiers and eligible queue bounds * no-mistakes(document): Document Bearings labels and newest-first bounds * no-mistakes(ci): Updated the stock macOS Bash CI expectation from 56 to 59 Bearings tests. Verified the suite under /bin/bash 3.2: all 59 tests pass. git diff --check also passes * fix(bearings): surface return catch-up without blocking snapshots (#4248) * fix(bearings): report the away-return catch-up instead of refusing A captain returning from away and asking for bearings got zero bytes and an error: fm-bearings-snapshot.sh ran the away-return guard with `|| exit $?` before reading any fleet state, so the mere existence of the catch-up gate killed every bearings mode (and /ahoy with them). Bearings now consults that guard rather than obeying it. fm-afk-return.sh separates its two refusal branches by exit status, so an ACTIVE away window still refuses exactly as before - the right answer there is to run the return first - while return catch-up (exit 4) lets collection and projection proceed and is disclosed as one action-free `(return-catchup)` gate row, following the existing `(main-inventory)` precedent. It stays out of decisions_open: these blockers are firstmate-actionable, not the captain's own call, and the per-task blockers already project as their own Underway rows. The guard's refusal text also stops promising a blocker list it cannot produce: a gate retained for a lifecycle reason alone now names that retention reason, and bearings carries the same reason in the gate row's title. Reporting is not ordinary work. AGENTS.md already scopes the return hold to work rather than reporting, so only the /afk and bearings skills needed the correction. * no-mistakes(document): Refresh away-return Bearings verification * no-mistakes(review): Reserve catch-up gate outside Bearings truncation * no-mistakes(review): Preserve filed dates in catch-up gate output * no-mistakes(document): Document reserved catch-up gate projection * fix(bin): address the home's backlog from any directory and detect a forked code-root copy (#4223) * fix(backlog): address the home's backlog from any directory and detect a forked code-root copy A home outside the code root forks its queue: the tracked .tasks.toml names data/backlog.md relative to tasks-axi's working directory, so a bare tasks-axi call from the code root writes the code root's data/ while session start, spawn, and teardown use $FM_HOME/data. Linking the code-root copy into the home does not hold, because tasks-axi 0.2.4 writes by renaming a temp file over its target and rename(2) replaces a symlink: add, start, hold, and done from the code root each turn the link back into a regular file. The archive path is resolved against the working directory too, even with --file. bin/fm-tasks-axi.sh runs tasks-axi against this home's backlog from any directory, using the lifecycle transitions' existing addressing (run from the data directory's parent, pin <data>/backlog.md through TASKS_AXI_FILE). It keeps relative --to/--*-file arguments meaning the caller's paths, and refuses a caller --file, an unresolvable home, and a symlinked home backlog. The fm-send hold lookup, fm-public-followup, and the fm-decision-hold shim, which relied on cwd discovery, now go through it with an explicit FM_HOME and a cleared data override, so they keep addressing exactly $FM_HOME/data and an ambient TASKS_AXI_FILE cannot divert them; every agent-facing backlog command names it instead of bare tasks-axi. Bootstrap gains a detect-only BACKLOG_RECONCILE check, also run read-only: when the home's data directory is not the code root's, a code-root data/backlog.md or data/done-archive.md that is not the home's own file is reported as a fork, with the merge procedure in bootstrap-diagnostics. * test(teardown): assert the completion hint names bin/fm-tasks-axi.sh ready The completion hint now points at the home-addressed command instead of a bare tasks-axi call, so the dependency-cleared follow-up assertion checks for that command. * no-mistakes(test): clear ambient tasks-axi env in tests/lib.sh * no-mistakes(document): drop bare tasks-axi example from cd-guard doc * no-mistakes(lint): replace ls -A decoy listing with find for SC2012 * no-mistakes: apply CI fixes * revert: keep the compliance gate unchanged; the synchronize race is filed separately * fix: pre-register Claude trust for secondmate homes (#4262) * fix(spawn): pre-register Claude workspace trust for secondmate homes A claude --secondmate launch skipped workspace-trust registration entirely, so a standalone-clone secondmate home (an explicit ~/fm-homes/<id> path) had no store entry and its pane wedged on the "Is this a project you trust?" dialog before it read its charter. The step was gated on the task kind rather than on the harness, so the spawn's fail-closed guard had nothing to run against and reported a launch that could never start work. fm-claude-trust.sh gains a secondmate-home mode. A secondmate home is a whole firstmate instance, produced either as a leased worktree or as a standalone clone, so the linked-worktree test cannot decide it and the seed is the evidence instead: the .fm-secondmate-home marker must be a regular file this user owns naming exactly the id being spawned, the home must hold AGENTS.md and bin/, and each operational directory must resolve inside the home. That is the set fm-home-seed.sh writes and fm-spawn.sh's own home validation re-checks, so nothing wider than a home a secondmate spawn would launch into can earn home-level trust. The worktree path is unchanged, and still refuses a home. fm-spawn.sh now runs the registration for every claude launch and keeps refusing the spawn when it fails, rather than launching an agent that would wedge. * no-mistakes(document): Correct Claude secondmate trust guidance * fix: ignore superseded failed GitHub check runs (#4258) * fix(pr-merge): judge each required check by its current run When the base branch advances, GitHub cancels a pull request's in-flight run and re-triggers it. The cancelled run stays in statusCheckRollup beside the passing re-run, so the rollup can hold several runs of one check name at the same head while GitHub itself reports the pull request CLEAN. github_checks_not_green judged every run independently, so that superseded failure refused a genuinely mergeable pull request and pushed the operator toward a needless --allow-red. Group the rollup by the reported name and judge each check by its current run. Supersession is proven, never assumed: a name leaves the red set only when every one of its non-green runs is strictly older than one of its green runs, dated by the forge's own settled timestamp - a check run's completedAt once its status is COMPLETED, or a status context's createdAt - and only in the whole-second UTC form GitHub emits, which is the one spelling that orders correctly as plain text. A run with no such timestamp is never superseded, so a still-running, queued or undated run keeps its check red, and a name with no green run at all stays red. An unnamed entry is grouped alone so two unrelated unnamed checks are never treated as one. Every comparison is one-directional: it can only clear a failure a later success provably replaced, and never clears a check whose current run failed, is pending, or is missing. No other guard moves - the pull request must still be open, undrafted, mergeable, conflict-free and head-bound, and --allow-red still waives exactly its named check with every other check green. Live reproduction: PR #4224 read CLEAN with an old FAILURE and a newer SUCCESS for one check name and was refused; it now verifies, while #4208 and #4210, whose latest runs failed, still refuse. * no-mistakes(review): Use check-run start times for safe supersession * no-mistakes(document): Clarify GitHub check-rollup documentation * fix(bin): persist merge authority for poll-detected outcomes (#4266) * fix(merge): persist the merge authority on poll-detected merge outcomes The merge ledger tags a merge with the authority that permitted it while the away-posture record existed, but only the direct attended merge in bin/fm-pr-merge.sh recorded it. A merge the forge queued, or one the merge poll detected after the fact, published an untagged row, so exactly the merges no agent watched were the least auditable. bin/fm-merge-authority-lib.sh now owns that answer, read from the same structured sources the merge gate already used: the task's recorded yolo posture and the away-posture record's mechanical grant list, never prose. bin/fm-pr-merge.sh keeps its own refusal wording and gates on that answer; bin/fm-watch.sh only records it on the row its poll publishes, so reading the authority never becomes a second path to a merge. An unresolved answer records an untagged row rather than dropping the outcome or inventing an authority. * no-mistakes(review): Persist canonical merge authority for queued poll outcomes * no-mistakes(review): Harden merge authority persistence against lifecycle races * no-mistakes(review): Serialize poll authority publication with teardown * no-mistakes(document): Clarify persisted merge authority lifecycle * no-mistakes(ci): Added targeted SC2034 suppressions for the two public result assignments in bin/fm-merge-authority-lib.sh. Verified successfully with `CI=true bin/fm-lint.sh` * ci: supersede superseded PR CI and bound unbounded jobs (#4281) The 2026-09-12 Actions starvation incident found firstmate CI with no concurrency deduplication, so every superseded PR head kept its full 13-job fan-out, and four jobs with no timeout at all. Add per-PR supersession keyed on the PR number for pull_request events and on the unique run id for push events, cancelling only pull_request runs, so a new PR head replaces its own in-flight CI while every main push keeps its own group and is never cancelled. Add hang tripwires to the four previously unbounded jobs: 25 minutes for lint (measured at 14-16 minutes) and 5 minutes each for the coverage guard, the timing aggregate, and the repo invariants. Measured lane bounds are unchanged. tests/fm-ci-workflow.test.sh resolves the workflow's concurrency expressions against simulated pull_request and push contexts and holds every job's finite timeout. * test(watch): gate backlog-hold away-record fixture on tasks-axi (#4288) Every other make_hold_home caller in this file skips when tasks-axi is absent; this test was the one unguarded call, so hosts without tasks-axi hard-fail the fixture build instead of skipping. * fix(backlog): bound per-item backlog row reads so a wedged backend cannot blind a session start (#4027) * fix(bin): bound each backlog row read so one wedged backend cannot blind a session start bin/fm-bootstrap.sh's reconcile and close-replay sweeps read the backlog backend once per item through fm_backlog_row_show, and that read was unbounded. A single wedged `tasks-axi show` therefore consumed the whole FM_SESSION_START_TIMEOUT and truncated the digest before the wake queue, supervision instructions, fleet state, and context sections ever printed, leaving the fleet unsupervised with no live watcher. The harm was a blind startup, not a slow one. Bound the read with the existing shared timeout primitive (bin/fm-timeout-lib.sh), so a wedged backend degrades to a loud partial reconcile: the sweep's existing BACKLOG_RECONCILE diagnostic names the item it could not read and the loop continues to the next one. The first bound hit also latches FM_BACKLOG_ROW_SHOW_WEDGED, so a sweep over many items pays one bound rather than one per item and still names every item it skipped, which is what keeps the digest whole on a home carrying a large fleet. The bound holds regardless of any particular tasks-axi install, so it does not depend on the 0.2.5 `show` hang being resolved separately. * fix(bin): set the wedged-backend latch where it survives, and prove it The latch added with the read bound was inert. fm_backlog_row_show runs inside a command substitution in both of its status-capturing callers, so the subshell read the inherited value correctly but its write died with the subshell. Every item still paid a full bound and reported `exceeded`, never `skipped`, which left the large-fleet case the latch existed to cover completely uncovered. Move the write to the two callers that capture the read's status and own the surviving shell, and leave fm_backlog_row_show reading the latch only. Correct the comments that claimed an ownership the function never had. The test that was supposed to cover this asserted only that the second read finished under a generous ceiling, which is true whether or not the latch works. Assert instead that a latched read is strictly faster than one bound and that it reports its own item as skipped, so an inert latch fails the test. * test: cover every item the wedged-backend latch skips The latch assertion exercised a single skipped item, so "every skipped item is still named" was inferred rather than tested. Probe three items instead and assert each skipped one names itself and costs less than a bound. Verified as a real guard by removing both latch writes: the suite then fails on the first skipped item instead of passing. * no-mistakes(review): distinguish backlog read-bound hits from absent rows * no-mistakes(review): preserve read-bound status through the captain verify gates * no-mistakes(review): Preserve backlog read-bound hits through resolve_entry and reconcile instead of spending them as absent rows * no-mistakes(review): Preserve backlog read-bound 124 through migrated-prefix scan and remaining task_show call sites * no-mistakes(document): Document bounded backlog row reads and FM_BACKLOG_ROW_TIMEOUT_SECS * no-mistakes(ci): Fixed all four failing CI checks with one root-cause fix plus one test-heredity fix. (1) bin/fm-captain-hold.sh: task_show carries the row in TASK_SHOW_OUTPUT and emits no stdout, but four call sites still used the stale command-substitution convention show=$(task_show ...), leaving show empty: task_show_or_fail (every captain hold failed with 'did not retain its hold-set stamp' - broke fm-captain-hold-lifecycle in parallel 1 and fm-bearings-board in serial 3), resolve_migrated_entry (migrated-prefix resolution could never match), reconcile-requests (existing rows were refused as absent), and command_open --identity (printed a constant '#0' identity, so fm-watch-triage's re-held captain call inherited the previous call's silence in serial 1). This is also the Greptile P1. Fixed by invoking task_show in the current shell and reading show=$TASK_SHOW_OUTPUT, the convention the other eight call sites already use; read-bound hits still stop loudly by name. (2) tests/fm-backlog-read-bound.test.sh (serial 4, unclassified family): the new e2e half implicitly relied on the author's process tree containing a harness process so fm-lock.sh would grant the fleet lock; on CI runners the lock is refused, the reconcile sweep is skipped, and the final BACKLOG_RECONCILE assertion fails. Reproduced by simulating a CI ancestry via a ps shim, fixed by pinning the lock evidence with the established fake-ps harness fixture pattern from tests/fm-session-start.test.sh. Verified: shellcheck clean; parallel-1, serial-3, and serial-4 lanes fully green locally (failed=0); serial-1 lane green except fm-gemini-harness, which fails only under local Node v26 (comm=node-MainThread); CI's default Node 22 reports comm=node, the branch that test passes on, so it is not a CI failure * no-mistakes(document): Verified bounded backlog read docs accurate across branch * fix(merge): serialize away authority with synchronous merges (#4285) * fix(merge): serialize the away-authority check with a synchronous merge bin/fm-pr-merge.sh read the away-posture record for merge authority (the per-task merge grant and the yolo/away-grant decision) and handed the merge to the forge afterwards. An archive at the captain's return or a grant revoked by a replacement record could land in between, so a merge could proceed on away authority that no longer held. The away record now carries a cross-subsystem lock, built on the existing bounded lock primitive rather than a new lock format: the record-mutating subcommands hold it across their mutation, and the merge holds it across both its authority read and the forge command. Because a queued or auto merge returns before the pull request lands, and would therefore outlive the lock, an away merge is now refused whenever it could land asynchronously: a requested --auto, a base branch whose merge-queue state does not prove an immediate merge, and GitLab's asynchronous flags and configuration. What remains permitted while away is the synchronous merge that lands inside the lock. This closes the common away-record/merge race against a live lock owner. It does not make the merge atomic in every case, and two narrow races are accepted and documented at their sites rather than hidden, both confused-agent-grade in the sense bin/fm-lease-lib.sh already uses: - A merge-queue rule change or a PR base change in the window between the queue-free preflight and the forge call can still enqueue the merge, which can then land after its grant lapses. - Killing the lock-owning shell while its gh or glab child is still running lets stale-owner recovery reclaim the lock and the record be archived or replaced, after which the orphaned child can complete the merge on lapsed authority. Closing either one needs landing verification or an ownership handoff, which is deliberately out of scope here. No existing gate is relaxed. The lock is taken after the live green-at-head verify and the captain-hold check, the in-lock authority read is unchanged, and a lock that cannot be taken refuses the merge rather than proceeding unlocked. The away grant stays a structured field; no prose is parsed. * no-mistakes(review): Fix GitHub rollup fixture base branch * no-mistakes(document): Document atomic away-authority merge locking * no-mistakes(ci): Updated two executable GitHub API fixtures to include the required baseRefName. Both previously failing test suites now pass: fm-captain-hold-lifecycle.test.sh and fm-pr-check-security.test.sh. git diff --check also passes * feat(bin): add Antigravity CLI (agy) as third worker/scout adapter (#4200) * feat(agy): verify Antigravity CLI as third worker/scout adapter Detection by anchored ancestry in fm-harness.sh (no marker of its own); bootstrap harness and effort validation; launch template with model and effort mapping plus reachable-catalog model validation; rendered-tail busy fallback in fm-busy-lib.sh with delivery footer in fm-composer-lib.sh; control mechanics with crewmate/scout-only refusal; tmux liveness naming; router entry with concise adapter reference; dated verification record; portable regression plus opt-in live drift guard. Verified live on agy 1.2.0: supervised spawn, durable steering, same-copy relaunch, and exit, with Herdr-native busy agreement. * no-mistakes(review): bound agy model probe, gate trust dialog, narrow busy signature * no-mistakes(review): pre-register agy workspace trust, make readiness gate strict * no-mistakes(review): Close Orca terminal on gate failure; isolate live-guard HOME; tighten agy matching * no-mistakes(document): Document agy adapter in stale harness enumerations * no-mistakes(review): Clamp non-positive FM_AGY_MODELS_TIMEOUT to the default bound * no-mistakes(document): Fix stale test-shard snapshots after agy lane additions * no-mistakes(ci): Fixed ci-3 (tests/fm-agy-harness.test.sh:519). Root cause: the agy spawn fixture's default base PATH (/usr/bin:/bin:/usr/sbin:/sbin) omits node's directory, but the spawn drives the real bin/fm-agy-trust.sh (which hard-requires node to record trust) and the fixture's fake tmux trust lookup (node -e) under that PATH. On the ubuntu-latest CI runner node lives in the toolcache (/usr/local/bin), so trust pre-registration failed on portable serial 2; on typical Arch hosts node is in /usr/bin, masking the defect. Fix (smallest, following the existing tests/fm-kimi-harness.test.sh precedent of carrying the interpreter's resolved directory): resolve node from the invoking environment (failing the test with 'test needs node' if absent, as kimi does for python3) and prepend its directory to the fixture's default base PATH; the FM_TEST_BASE_PATH override contract is untouched. Verified locally: (1) pre-fix reproduction with a CI-shaped base PATH (system bins minus node) produced exactly the reported failure — 'node is required to record workspace trust and was not found on PATH' plus the fake tmux 'node: command not found'; (2) post-fix, all 29 tests in the file pass both with node available only via a leading non-standard dir in the base PATH (CI's shape) and with the default base PATH on this host. bash -n clean; ShellCheck is not installed in this worktree (previously recorded as environmental) * no-mistakes(test): Give agy typed sends a longer submit-confirm budget * no-mistakes(document): Document agy send budget, trust gate, and control coverage * no-mistakes(document): Document agy busy fallback inventory and send-timing evidence * feat(afk): add quiet supervision mode for a present captain (#4337) * feat(afk): add quiet supervision mode for a present captain Adds a first-class quiet supervision mode alongside /afk for kunchenguid/firstmate#2356: the same away-mode daemon, injection, busy/composer guards, classification policy, and reliability properties, but the captain staying present and chatting no longer exits it - only an explicit /quiet off does. state/.afk's first line now declares its mode (away, the default, or quiet); fm_afk_mode() in bin/fm-wake-lib.sh is the single reader, falling back to away for missing/empty/unreadable/unrecognized content (including the legacy bare-epoch-timestamp format written before mode existed) so nothing regresses. fm_afk_flag_write() preserves the on-disk mode on a bare refresh (no explicit mode given) rather than defaulting to away, which is what keeps the daemon's own redundant terminal-side re-write from silently resetting a captain's quiet mode back to away underneath them. New .agents/skills/quiet/SKILL.md is a thin wrapper cross-referencing /afk for every shared mechanism, per the one-owner rule. AGENTS.md gains the state/.afk table entry and section 8's exit-trigger line. bin/fm-supervision-instructions.sh, bin/fm-session-start.sh, and bin/fm-guard.sh's stale-watcher banner all become mode-aware so a quiet-mode captain is never misdirected to /afk in captain-facing text. Closes #2356 * no-mistakes(review): Fix AFK epoch parsing and quiet-mode digest wording for two-line flag * no-mistakes(document): Fix turnend-guard.md daemon-ownership contract for quiet mode --------- Co-authored-by: NewAiCoder <claude@theinbtw.com> Co-authored-by: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> * fix(bin): let verified harness ancestry outrank retained markers (#3) (#3578) * fix(bin): let verified harness ancestry outrank retained markers (#3) * fix(bin): let a structural harness ancestor outrank a retained marker bin/fm-harness.sh treated a verified environment marker as unconditionally authoritative, so a Codex session started from an environment that had retained CLAUDECODE=1 detected as claude. Session start then emitted Claude's Stop-owned supervision protocol to a Codex primary, and every turn end was blocked for missing Claude recovery. The defect is the precedence boundary, not any one harness. codex, opencode, kimi, and muse publish no identity marker at all, so with markers winning outright any retained CLAUDECODE renamed them; the Cursor-before-Claude ordering was a point patch on the same class of problem, and the launch-time marker clearing only ever covered sessions fm-spawn started. Markers and ancestry are now separate evidence layers that detect_own arbitrates: - no ancestry match, or no marker: the single available layer answers, unchanged; - same harness family: the marker's finer verdict stands, so a launch-selected pi-signed is not flattened to pi by an ancestry walk that can only see the shared launcher name; - different harness with a structural (command-name) ancestor: ancestry wins, because only ancestry proves who owns the process tree; - different harness with only a bare-interpreter script-path match: the marker wins, since a harness-shaped path in some node process's arguments is weaker evidence than a harness publishing its own identity. The correction is symmetric: a retained CURSOR_AGENT no longer renames a claude worker nested under cursor either. Adds fm-harness.sh ancestry [<pid>], ancestry evidence with no marker layer, so a real harness process can be asked what the walk makes of it. tests/fm-harness-precedence.test.sh is the portable regression, built from real renamed processes with no harness installed. Every case drives the two layers apart and asserts each alone as well as the combination, so no case can pass vacuously; it also pins Codex's real two-process install topology, since the fix depends on the native binary being what a tool subprocess meets first. The opt-in drift guard gains the matching live half: each installed harness's real running process must still be identified by the ancestry walk, and it fails naming the harness and version when a release changes that name. Documentation follows the corrected contract in the script header, the harness-adapters detection section, the codex, opencode, kimi, and cursor references, and a dated verification record. * fix(tests): drop the unused argument pass-through in the shim-topology helper bin/fm-lint.sh refused the branch: run_shim declared a `[ancestry]` argument and forwarded "$@", but every call site that varies the environment or passes the ancestry subcommand invokes the shim entry point directly, so the helper is only ever called with no arguments (ShellCheck SC2120/SC2119). Behavior is unchanged: with no arguments "$@" expanded to nothing. * fix(bin): examine the top of the process chain instead of assuming init harness_ancestry stopped as soon as the next pid was 1, on the assumption that pid 1 is always init and can never be a harness. Inside a PID namespace that assumption inverts: the harness itself is pid 1, so the walk never examined the one process that proves who owns the tree, reported no ancestry at all, and handed the verdict straight back to a retained marker. A real Codex session under `codex sandbox`, holding CLAUDECODE=1 and CLAUDE_CODE_ENTRYPOINT=cli, is exactly that shape: it resolved claude and rendered Claude's Stop-owned supervision protocol even with the marker-vs-ancestry precedence boundary in place. The same probe now resolves codex and renders the Codex foreground checkpoint. A host's real pid 1 (init, systemd, launchd) matches no harness name, so examining it costs one ps call and can introduce no false positive; the walk still stops once that top process has been read, and a non-numeric or zero ppid still ends it. tests/fm-harness-precedence.test.sh pins the namespace shape with a fake ps that reports every process as bash with ppid 1 and pid 1 as the harness. The case asserts the marker still answers alone when pid 1 is host-shaped, so it cannot pass vacuously, and it fails against the previous stop condition. * docs(verification): record the real-Codex retained-marker evidence The existing record proved the precedence boundary with the portable regression and recorded each installed harness's process name behind the ancestry walk, but it had no evidence from a real Codex process actually holding a retained Claude marker, which is the failure the boundary exists for. Adds the dated before/after result from codex-cli 0.152.0 under `codex sandbox`, with the exact command and the decisive verdict and rendered protocol on each side, and records the second boundary that shape exposed: the walk must examine the top of the process chain, because inside a PID namespace the harness is pid 1. Refreshes the portable regression's observed output for the case it gained. * no-mistakes(review): blind ancestry in marker-pinned harness tests * no-mistakes(review): blind ancestry in the Pi guard-routing test * no-mistakes(review): classify precedence suite, dedupe ps stub, soften claims * no-mistakes(review): model the spawn-and-wait Codex shim topology * no-mistakes(document): correct stale muse marker-clearing detection claims * no-mistakes: apply CI fixes * fix(bin): examine the top of the chain in the lock and nudge walks too The pid-1 defect corrected in bin/fm-harness.sh survived unchanged in the two other harness-ancestry walks, on the exact topology the branch verified against a real Codex process. bin/fm-session-lock-lib.sh's fm_harness_ancestry_pids stopped as soon as the next pid was 1, so a firstmate whose harness is pid 1 of its own PID namespace could not find that harness at all and did not recognize its own session lock. bin/fm-sessionstart-nudge.sh carried the same stop plus a blanket rejection of a lock pid of 1, so the same session was told to run session start again on every turn. Both walks now compare the top process before stopping, matching the shape used in bin/fm-harness.sh. For the lock walk this is safe because fm_harness_process_matches rejects a host's real pid 1. For the nudge, `kill -0` still gates the lock pid, and on a host an unprivileged `kill -0 1` fails, so a lock file that wrongly nam…
friesentius
pushed a commit
to friesentius/firstmate
that referenced
this pull request
Sep 21, 2026
* Bind GitHub merges to a live green head and require an away-task grant. A GitHub merge now re-reads the pull request and passes --match-head-commit, so a red or moved head cannot land the way GitLab already refused. While an away record exists, only yolo or a named grant may merge, so hold-for-return cannot ship an ungated PR. Co-authored-by: Cursor <cursoragent@cursor.com> * no-mistakes(review): Harden away merge authorization and grant parsing * no-mistakes(review): Restrict fallback outcomes to proved GitHub merges * no-mistakes(document): Refresh merge safety documentation * no-mistakes(ci): Fixed all three CI failures by updating legacy GitHub merge fixtures for live-head verification/direct gh merges and removing a process-event runner cleanup race. Verified fm-pr-check-security, fm-captain-hold-lifecycle, and fm-watch-triage pass locally; shell syntax and git diff checks also pass * no-mistakes(document): Document attended red-check exception --------- Co-authored-by: Cursor <cursoragent@cursor.com>
friesentius
pushed a commit
to friesentius/firstmate
that referenced
this pull request
Sep 21, 2026
* Bind GitHub merges to a live green head and require an away-task grant. A GitHub merge now re-reads the pull request and passes --match-head-commit, so a red or moved head cannot land the way GitLab already refused. While an away record exists, only yolo or a named grant may merge, so hold-for-return cannot ship an ungated PR. Co-authored-by: Cursor <cursoragent@cursor.com> * no-mistakes(review): Harden away merge authorization and grant parsing * no-mistakes(review): Restrict fallback outcomes to proved GitHub merges * no-mistakes(document): Refresh merge safety documentation * no-mistakes(ci): Fixed all three CI failures by updating legacy GitHub merge fixtures for live-head verification/direct gh merges and removing a process-event runner cleanup race. Verified fm-pr-check-security, fm-captain-hold-lifecycle, and fm-watch-triage pass locally; shell syntax and git diff checks also pass * no-mistakes(document): Document attended red-check exception --------- Co-authored-by: Cursor <cursoragent@cursor.com>
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
Build C1 (GitHub live green-head bind) and C2 (per-task away merge grant) together in one PR, yolo off, so the away-grant never exists without the C1 green gate.
Single PR: C1 and C2 land atomically. This overrides shipping them as two separate PRs.
Attended-only --allow-red "" is part of C1. It waives only that explicitly named GitHub check, still requires every other check green, and still binds the merge to the live head. Hard safety: --allow-red is attended-only and must be refused while an away record exists, so a red merge with nobody watching stays closed. --allow-red is distinct from --attended-override, which only re-enables the forge flags --auto, --admin, --delete-branch, and -d. Neither bypasses the live green gate, the C2 grant, or a captain hold.
C1: In fm-pr-merge.sh, GitHub must mirror GitLab. Reimplement github_checks_not_green clean; do not cherry-pick the retired phase-2 branch. Run that check unconditionally at the live head. Merge with a direct gh pr merge --match-head-commit ; gh and jq are required for a GitHub merge and their absence refuses before recording. Live GitHub conditions: open, not a draft, MERGEABLE, not DIRTY, every check green at the current head; report every failing condition. Deny --auto, --admin, --delete-branch, and -d (including short-flag clusters) by default. --attended-override re-enables only those denylisted forge flags and never skips green, grant, or hold. Caller --sha and --match-head-commit are refused because the head comes from the live read. Queue retry text must include --attended-override so the operator is not steered into a guaranteed refusal.
C2: At /afk, record captain-named task ids with repeatable --grant as a structured merge_grants field on the away record (empty form is merge_grants: -). Never infer task ids from clause prose or away words. Invalid or duplicate ids are usage errors. While the away record exists, merge for task X proceeds only if that task's meta yolo=on or X is in the grant list; otherwise refuse with "error: task $ID is held for the captain return". Unreadable away records refuse rather than skip. A grant is not a hold-release. yolo=on still hits C1; the grant is merge-when-green, not merge-when-red. If meta already has pr= and it is not exactly the merge URL, refuse. The grant lapses when the record archives; archived copies are not consulted. Ledger rows add yolo or away-grant when the away record existed at merge time. Red-check exceptions stay words for judgment and are not executed.
Not in this PR: required_status_checks (later, gated on PR 4151), parking main (C3), and CUT items including fm_lease_justify, --posture, --clause, --citation, and fm-afk-alarm.sh.
Option C: phase-2 is retired and its branch stays untouched as reference. C1 closes the hole that a red GitHub PR can merge because the GitHub path had no green check. C2 is a structured task-id grant, not natural-language clause authority.
What Changed
ghmerges to that SHA, and restrict red-check waivers and protected forge flags to attended overrides.yolo=onor a matching grant while away, failing closed on unreadable records and annotating merge outcomes with their authority.Risk Assessment
✅ Low: The merge-safety changes are well-bounded, satisfy the stated C1/C2 requirements, and introduce no additional material source-verifiable issues beyond explicitly accepted residual races and audit gaps.
Testing
Ran the three targeted behavior suites, exercised the AFK grant lifecycle and malformed-record defenses through the real CLI, and drove authenticated GitHub refusal paths against an open PR while verifying its head and open state remained unchanged; all exercised scenarios passed, while an actual successful remote merge was intentionally left untested without an authorized disposable PR.
Evidence: AFK grant lifecycle transcript
Source: AFK grant lifecycle transcript
Evidence: Malformed merge-grants validation transcript
Source: Malformed merge-grants validation transcript
Evidence: Live GitHub green-gate transcript
Source: Live GitHub green-gate transcript
Evidence: Away merge guards transcript
Source: Away merge guards transcript
Evidence: Unreadable away-record merge refusal transcript
Source: Unreadable away-record merge refusal transcript
Evidence: Merge CLI argument guards transcript
Source: Merge CLI argument guards transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 5 issues found → auto-fixed (2) ✅
bin/fm-pr-merge.sh:768- Away authorization is a TOCTOU check with no coordination against away-record publication or replacement. A merge can observe no record at lines 768/929, then/afk confirmcan publish an ungranted record beforegh pr merge;--allow-redcan consequently merge a red PR while nobody is attending. This contradicts “--allow-red ... must be refused while an away record exists” and “While the away record exists, merge for task X proceeds only if ...”. A durable fix requires a shared lock spanning away-record transitions and merge authorization/forge invocation, so that synchronization change needs user authorization.bin/fm-merge-outcome-lib.sh:53- The new authority suffix is supplied only by fm-pr-merge, while bin/fm-watch.sh:1977 still reports polled merges without it. For a direct merge, the watcher can observe the landed PR and win the notification lock before fm-pr-merge reports; the later authority-bearing call is deduplicated, leaving an untagged ledger row. Queued merges always use the poll path. This contradicts “Ledger rows add yolo or away-grant when the away record existed at merge time.” Resolving asynchronous queue semantics may require persisting merge authority, so ask the user before extending state.bin/fm-afk-contract.sh:430- The parser deliberately treats a missing merge_grants field as valid. Thus a version-1 record missing the required structured field validates, and a task with yolo=on proceeds without detecting the malformed record. This conflicts with “empty form is merge_grants: -” and “Unreadable away records refuse rather than skip.” The legacy-record fallback is not required by the stated intent; confirm whether to remove it and require the field.bin/fm-afk-contract.sh:424- A present scalarmerge_grants: -followed by indented list data, or a duplicate top-level merge_grants field, is accepted because the parser exits or ignores content after the scalar instead of rejecting it. With yolo=on, require_away_merge_grant then permits a merge despite an unreadable away record. Tighten the parser so any content attached to the scalar and duplicate definitions fail validation.bin/fm-pr-merge.sh:141- The new allow-red parser adds unrequired acceptance paths: multiple --allow-red options waive multiple checks, and --allow-red=<name> is accepted. The intent specifies--allow-red "<named check>"for one explicitly named check and, unlike --grant, does not make it repeatable. Remove the equals alias and reject duplicate occurrences unless the user explicitly wants this broader interface.🔧 Fix applied.
1 warning still open:
bin/fm-pr-merge.sh:652- The change adds an unrelated fallback acceptance path: when the queue-aware gh read fails, any readable gh-axi state now proceeds to detailed outcome handling, whereas previously only a proved merge did. Neither C1 nor C2 requires broadening post-merge fallback behavior; remove this branch expansion and retain the prior merged-only fallback unless the user authorizes the additional behavior.🔧 Fix applied.
1 warning still open:
bin/fm-pr-merge.sh:231- The new GitLab--remove-source-branchdenylist path exceeds the stated C1 scope. The intent says--attended-overridere-enables only--auto,--admin,--delete-branch, and-d, but this change also rejects and re-enables GitLab's separate--remove-source-branchoption. Remove this added matching path and its corresponding tests unless the broader GitLab behavior is explicitly authorized.✅ No issues found.
✅ **Test** - passed
✅ No issues found.
bash tests/fm-pr-merge.test.shbash tests/fm-afk-contract.test.shbash tests/fm-pr-merge.test.shLivefm-afk-contract.sh propose/confirm/grants/archiveworkflow in an isolated homeLive malformed, duplicate, and legacymerge_grantsrecord validationLivefm-pr-merge.shargument and away-grant refusal checksLive authenticated GitHub reads against PRs 4198 and 4195 to exercise conflicting/closed and non-green conditions without mutating GitHubgit status --shortand transient-test-directory check after cleanup✅ No issues found.
bin/fm-test-run.sh tests/fm-afk-contract.test.sh tests/fm-pr-merge.test.sh tests/fm-captain-hold-lifecycle.test.shIsolatedbin/fm-afk-contract.sh propose,confirm,grants, andarchivelifecycle with two task grantsIsolated validation of duplicatemerge_grantsand list content attached to scalarmerge_grants: -Livebin/fm-pr-merge.shinvocation against GitHub PR 4158 with one named red-check waiver while another check remained redLive away-mode merge attempts for an ungranted task, a granted task using--allow-red, and a yolo task with a malformed away recordCLI adversarial checks for--allow-red=<name>, duplicate--allow-red, unattended--auto, and caller-supplied--match-head-commit✅ **Document** - passed
✅ No issues found.
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
✅ No issues found.