fix(bin): decide session-lock ownership by session cohort and make the turn-end fail-open reachable - #2929
Conversation
Two defects with one root cause, plus the fail-open they exposed and a third identity gap the new live guard found. Ownership was decided purely by walking the current shell's ancestry, so a session whose harness rehosted its work under its own pty - a tree that reparents to init and never reaches the pid holding the lock - reported itself as a competing session and degraded session start to read-only. Ownership now also accepts a session-cohort proof: the lock names a live harness that provably STARTED this session, from the launch marker the harness writes into every process it spawns, AND is co-located with it in the same multiplexer pane or on the same controlling terminal. The two signals are AND-ed, not OR-ed, so a genuinely separate concurrent session sharing one pane is still refused, and the absence of either signal falls back to the ancestry verdict rather than widening ownership. A suspended holder never released the lock, because kill -0 succeeds on a stopped process. A durably stopped harness - confirmed over several samples so a momentary stop is not misread - no longer counts as holding: it mutates nothing while stopped and nothing guarantees it resumes, so treating it as a holder converted a recoverable state into a permanent lockout of the whole home. fm-lock.sh status reports such a holder as reclaimable and acquisition names any takeover from a live holder. The turn-end guard's bounded attended fail-open required an auto-arm that had recorded an exhausted failure, so an auto-arm that never ran at all left no failure to advance and the guard re-blocked until Claude's hard 8-block override. It now opens on a second, independent condition - a verified absent auto-arm - under the same block budget and one-shot alarm, so supervision being unstartable for a reason the guard cannot repair always reaches the valve however it arose. The new live guard also found that npm-installed codex reports comm MainThread with no interpreter name, and was not identified as a harness at all. Identity for that shape now comes from the interpreter's script path in argv, by whole path component only. Covered by tests/fm-session-lock-ancestry.test.sh with real processes and no harness, including the divergence each case rests on, and by the opt-in tests/fm-session-lock-identity-live-e2e.test.sh against every installed harness.
FM_SESSION_LAUNCH_MARKERS holds exactly one entry, so the same-session cohort proof can only ever fire for Claude. codex, opencode, pi, pi-signed, grok, kimi, and cursor are decided by process ancestry alone, which means a session those harnesses rehost outside its own process tree still refuses its own home. That is the unfixed half of the defect for those adapters, and nothing in the mechanism said so. Records it at the mechanism's own owner, with the reason it is safe rather than merely incomplete: a missing marker removes an accept path and never a refusal, so a marker-less harness lands exactly on its previous behavior, while a guessed variable name either buys nothing or gets believed as half the proof that keeps a separate concurrent session out. Extending the table is therefore a verification task, not an editing one. The recovery-boundary contract and the per-harness evidence record carry a pointer to it, so the third non-competing holder cannot be read as cross-harness behavior. No mechanism change: the library diff is comment-only.
…pling fail closed
…v path components
The cohort's acceptance typing compared the reported command name for exact equality with a verified harness name, and the Claude Code install actually in use never satisfies it. Its executable is a single-file native build named claude.exe, so a process that execs it reports that name verbatim; and it renames its own background pty worker task to `claude bg-pty-host`, which the 15-character kernel task-name limit cuts to `claude bg-pty-h`. Both shapes were identified as a harness and typed as nothing, so the cohort refused and the session start degraded to read-only against its own home while it was the only session alive - the primary defect, for the shape actually installed. Normalize the reported command name before the SAME exact-equality test: take its first space-delimited word, undoing the truncation, then strip the platform executable suffix. The comparison itself is unchanged, so this cannot become a prefix rule - claudette, claude-code, node and python3 still name nothing - and path components are untouched, so pi keeps the basename anchoring that stops an interior /home/pi component from naming a harness. Neither step reaches the script-path caller, which sees neither artifact. fm_harness_exec_kind opened with a fourth hand-written copy of the loop that fm_harness_basename_name already is; it now delegates, leaving one owner for the exact-equality test. Every fixture in the suite and in the live guard previously symlinked an exact harness name, so neither could observe a suffixed or truncated command name at all, which is why this went unnoticed. Both now carry claude.exe and the truncated worker as acceptance cases with claudette, claude-code, node and python3 as negative controls. The portable cases run real processes, so the kernel and ps produce the name rather than a stub, and each asserts the divergence before the verdict so a platform that stopped producing an artifact fails instead of passing vacuously. The live guard additionally launches each harness as its own INSTALLED executable, which is the only way to see what a vendor actually calls its binary, and fails naming the harness and version; an interpreter script is reported rather than failed, because its own name never reaches a reported command name. The verification record is refreshed from a real run on this machine, and the npm-layout recognition limit is narrowed to what is still true: the observed Claude Code npm install is identified by its own executable name, and only a node-hosted launch out of that layout stays unnamed.
…marker verification pointer
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Reviews (2): Last reviewed commit: "no-mistakes(document): widen FM_PROC_ROO..." | Re-trigger Greptile |
dc52b37 to
96a879c
Compare
|
Speaking as Kun's firstmate: Scheduled 11:10pm PT 8/23 pass. VISION.md read in full from current main VISION (inspected
Class: corrective (false-refusal of the owning session + unreachable turn-end fail-open). Not auto-eligible: covering lock PRs remain open, and this does not clearly supersede #2437. Security: no Workflow-approval this pass (captain
CI / NM: HEAD Overlap / HOLD: covering #2437 still OPEN (sidecar Waiting on the author for a HEAD-matching attestation (and green CI after this approval). The covering-PR hold is a captain/maintainer mechanism choice, not something the author can clear by claiming supersession unless they say so AND it verifies. Do not rebase. Land-eligible: NO. Captain-flag NOW: no. |
Intent
GOAL: Fix the session-lock identity gap in the firstmate repo, and the unreachable turn-end fail-open it exposes. Two defects with one root cause, both measured on a live fleet on 2026-08-17.
DEFECT 1 - ownership is decided by the process tree, and the tree is not the session. fm_harness_ancestry_pids in bin/fm-session-lock-lib.sh walked the current shell's ancestry and fm_session_lock_owned_by_self asked whether state/.lock names any pid in that walk. Two live failures. (1a) Work running beside the session window is refused: a Claude background session launched from the same window runs under "claude bg-pty-host -> claude.exe", a tree that terminates at /init and never includes the window's own claude pid that holds the lock, so bin/fm-lock.sh reported "another live firstmate session holds the lock" and the whole session start degraded to read-only while exactly one session existed. Both processes carried the same HERDR_PANE_ID. (1b) A SUSPENDED holder never releases: after a session resume the window's previous "claude --resume" sat in state Tl (stopped by Ctrl-Z) on the same tty, and fm_harness_pid_alive used kill -0, which succeeds on a stopped process, so the lock stayed pointed at something that would never let go and every subsequent session was refused.
DEFECT 2 - the turn-end safety valve cannot fire when the auto-arm never claimed. bin/fm-claude-stop-autoarm.sh used the same ownership test, so under defect 1 it never claimed the home and supervision never started. bin/fm-turnend-guard.sh then blocked every turn end while work was in flight, and its bounded attended fail-open was unreachable, because docs/turnend-guard.md made that path require an auto-arm that recorded an exhausted failure - and here the auto-arm never ran at all, so there was no failure episode to advance. The block therefore repeated until Claude's own 8-block override. The captain treats this as the MORE URGENT of the two: a correctness gap in identity detection must not be able to turn into an unbounded block, so the valve has to be reachable whenever supervision cannot start for a reason the guard itself cannot repair, however that reason arose, and it must stand on its own so that even an imperfect fix to defect 1 cannot wedge a session again.
NON-NEGOTIABLE SAFETY PROPERTY: a genuinely separate concurrent session must still be refused. The ancestry walk exists to stop two sessions mutating one home. Ownership must not be widened into something that would accept an unrelated session, and the existing protection that the walk stops at the first non-harness ancestor - so it cannot cross a gap into an unrelated harness further up the real tree - must not be removed.
DESIGN CONSTRAINTS THE CAPTAIN SET. Do not make a single runtime's environment variable the load-bearing mechanism: HERDR_PANE_ID is the signal that made the diagnosis obvious, but this repo supports tmux, herdr, zellij, orca and cmux, and sessions that run under none of them, so such a signal must be one of several and its ABSENCE must fall back to today's behaviour rather than opening ownership up. Every supported primary harness is in scope, not just Claude: claude, codex, opencode, pi, pi-signed, grok, kimi. For every harness except Claude the innermost match is the session, and Pi's signed wrapper can be the direct parent of the inner pi engine pid that actually owns the lock, so a fix tuned to Claude's nested worker chain that breaks that case is not a fix. The suspended-holder case wanted a deliberate decision, not a reflex, stated where the contract lives and made observable rather than surprising. Prefer deterministic, idempotent enforcement that converges safely on repeated execution, and keep invalid states failing closed where the runtime can enforce them.
ACCEPTED DESIGN, as implemented: ownership is decided by SESSION COHORT rather than by the process tree. The cohort requires TWO independent signals AND-ed together - a launch relationship plus co-location - so a single signal can never accept on its own, and the absence of a signal falls back to the previous ancestry-only behaviour rather than widening acceptance.
CAPTAIN DECISIONS FROM NINE REVIEW ROUNDS. These are settled; they are recorded here so a reviewer reading only the diff does not re-open them.
DELIBERATE, CAPTAIN-ACCEPTED LIMITS - these are stated truthfully in the code comments and the verification record and must NOT be treated as new defects.
TESTS - the captain required BOTH kinds because the verdict comes from process names, argv and process shape emitted by vendor harnesses, which makes this a harness-dependent check under the repo's firstmate-coding-guidelines rule.
DOCUMENTATION: patch the existing owners rather than appending new paragraphs, then update cross-references instead of duplicating. The contracts touched here live in bin/fm-session-lock-lib.sh's own header comments, docs/watcher-continuity.md which owns the session-lock recovery boundary, and docs/turnend-guard.md which owns the fail-open progression. AGENTS.md changes are held to the minimum the repo's knowledge-placement decision tree allows: a fact only needed in a nameable situation belongs in a skill or a doc with a one-line trigger, not inline. bin/fm-doc-audience-check.sh was run for every changed prose surface.
STYLE AND GATES: one full sentence per line in tracked Markdown, plain dash never an em dash, no agent co-author on commits. bin/*.sh must pass bin/fm-lint.sh, which is the single owner of the lint definition.
LIVE-FIRE CONTEXT: firstmate is running on these exact scripts. The work is done in an isolated worktree so the running copy is untouched until this lands, but a defect here can lock a real home out of its own fleet or wedge every turn, and the failure would appear only after adoption. The change therefore degrades to the previous behaviour on any uncertainty.
FAILURE MODES THAT COULD NOT BE EXERCISED, stated rather than hidden: opencode, pi, pi-signed, grok, kimi and cursor are not installed on this machine, so their real command names are unverified and the live guard reports each explicitly rather than passing over it; cross-harness kind distinctness needs two harnesses that name themselves by executable identity and only claude did here, so the guard says so rather than claiming the check ran; BSD ps behaviour on macOS is covered only by the fake-ps unit layer, not by a real macOS run; and on this WSL2 build SIGSTOP does not stop a pty session leader, only an ordinary child.
KNOWN PRE-EXISTING TEST FLAKINESS ON THIS MACHINE, not caused by this branch: tests/fm-watch-arm.test.sh is flaky on origin/main too; tests/fm-tmux-agent-liveness.test.sh and one Herdr-husk case in tests/fm-session-start.test.sh fail on origin/main here; and watcher suites can fail inside a loaded 15-suite family run while passing individually.
DELIVERY: mode is no-mistakes. Drive to a PR with green CI. The GitHub account has no write access to kunchenguid/firstmate, so the route is push to the fork adriantoczydlowski/firstmate and open the PR against origin.
What Changed
bin/fm-session-lock-lib.shnow decides session-lock ownership by session cohort in addition to the existing harness-ancestry walk: a live holder counts as this same session only when a verified launch marker (FM_SESSION_LAUNCH_MARKERS, Claude-only today and scoped to the asking harness) and co-location (FM_SESSION_CONTAINERSpane/session ids or shared tty) both hold, with marker pairs ordered by/procstart time and any missing signal falling back to the previous ancestry-only verdict. Acceptance is typed from strict executable identity only - command basename or a whole path component - with the reported command name normalized first (first space-delimited word,.exesuffix stripped) soclaude.exeand the procps-truncatedclaude bg-pty-htype asclaudewhileclaude-code,claudette,nodeandpython3still type as nothing;pi/pi-signedkeep their anchored matching. A newfm_harness_pid_suspendedtreats a durably stopped holder (confirmed over several samples) as reclaimable rather than a permanent owner.fm_session_lock_holder_competespredicate:bin/fm-lock.sh,bin/fm-claude-stop-autoarm.shandbin/fm-turnend-guard-cursor.sh.fm-lock.sh statusreports a suspended holder as reclaimable, and acquisition now names on stdout any live holder it yielded past - own-session holder in another tree, suspended session, or live-but-unidentified pid - instead of moving the lock silently.bin/fm-turnend-guard.shgainedfailopen_condition_verified, so the bounded attended fail-open opens on either an exhausted auto-arm failure episode or a verified absent/stale auto-arm epoch ledger (still gated on AFK, exhausted block budget, and no watcher or continuation), and the emitted notice states which of the three causes held.tests/fm-session-lock-ancestry.test.shwas extended with real-process cases for the rehosted owner, refused co-located stranger, refused launch-relationship-only pair, per-signal divergence, suspended holder, and the reported-command-name controls; a new env-gatedtests/fm-session-lock-identity-live-e2e.test.sh(registered inbin/fm-test-run.sh's live-harness-optin family) exercises every installed harness and reports absent ones explicitly;tests/session-signals.shderives the cohort signal list from the lib tables fortests/lib.shand both live guards.docs/verification/runtime-backends.mdrecords the dated per-harness result and stated limits, withdocs/watcher-continuity.md,docs/turnend-guard.mdand the harness-adapters skill updated in place.Risk Assessment
✅ Low: The only change since the last round is a purely conjunctive tightening of the cohort's launch-relationship signal that can refuse but never accept more, its invariant is provably sound rather than heuristic, it fails closed on any unreadable start time, it adds no platform limit the marker path did not already have, and its regression tests reproduce the accepted-before shape while a positive control on the identical fixture keeps the case from going vacuous; the one surviving finding is prose accuracy about a residual whose reachability requires a pid reassignment inside a single 10ms clock tick.
Testing
I exercised the three portable suites that own this change through both bash directly and bin/fm-test-run.sh (118 cases, zero failures), ran the opt-in live-harness identity guard against the real installed claude 2.1.241 and codex 0.139.0 (its per-harness output matches the record in docs/verification/runtime-backends.md, including the claude.exe and truncated "claude bg-pty-h" acceptance cases and the claudette/claude-code/node/python3 negative controls), confirmed both opt-in guards self-skip when their env gates are unset, and re-verified that bin/fm-test-run.sh --changed --base 1cb900c now resolves at rc=0 with all four owning suites selected. For product-level evidence I re-ran a before/after demo against the current head that drives the real bin/fm-lock.sh and the real Claude Stop-hook turn-end guard over real processes: on the base commit the background-worker-shaped session is refused with "another live firstmate session holds the lock", a genuinely SIGSTOPped holder reads as live and blocks acquisition, and five consecutive turn ends all block; on the branch head the background session converges the lock naming its co-located launcher, the suspended holder is reported reclaimable and taken over, an unrelated concurrent session in a different pane is still refused with the lock left naming it, and the turn-end guard blocks three times then opens exactly one bounded attended fail-open naming the never-claimed auto-arm before re-blocking. This change is CLI-only with no rendered UI surface, so the reviewer-visible artifact is the CLI transcript rather than a screenshot. The only non-green result is pre-existing and unrelated: tests/fm-test-run.test.sh's final case needs ruby to parse the CI workflow and ruby is not installed here. The worktree was left clean and all evidence written under the dedicated evidence directory.
Evidence: CLI transcript: base vs head for both defects and the safety control
Source: CLI transcript: base vs head for both defects and the safety control
BEFORE (base 1cb900c) 1(a) $ bin/fm-lock.sh -> error: another live firstmate session holds the lock (pid 1918872); operate read-only until resolved [exit 1] 1(b) $ bin/fm-lock.sh status -> lock: held by live harness pid 1918955 $ bin/fm-lock.sh -> error: another live firstmate session holds the lock (pid 1918955) [exit 1] safety -> refused (correct) turn end 1..5 -> exit 2, exit 2, exit 2, exit 2, exit 2 (fail-open unreachable) AFTER (head d5eb54e) 1(a) $ bin/fm-lock.sh -> lock acquired: harness pid 1919654 (converged onto this session's own holder pid 1919646 (launched this session; same container herdr=demo-pane-a)) [exit 0] 1(b) $ bin/fm-lock.sh status -> lock: held by SUSPENDED harness pid 1919910 (reclaimable: a stopped session is not holding this home) $ bin/fm-lock.sh -> lock acquired: harness pid 1919948 (took over from suspended harness pid 1919910) [exit 0] safety -> error: another live firstmate session holds the lock (pid 1920209); lock still names 1920209 (correct) turn end 1..5 -> exit 2, exit 2, exit 2, exit 0, exit 2 block 4: {"systemMessage":"FIRSTMATE SUPERVISION IS GENUINELY DOWN: 1 task(s) in flight, the Stop-owned auto-arm never claimed this home at all and left no attempt on record, no watcher or automatic continuation exists, and the block budget is exhausted. Keep this session attended and diagnose the automatic Stop-hook and watcher startup before relying on unattended supervision."}Evidence: Live-harness session-lock identity guard against real installed harnesses
Source: Live-harness session-lock identity guard against real installed harnesses
$ FM_SESSION_LOCK_IDENTITY_LIVE=1 bin/fm-test-run.sh tests/fm-session-lock-identity-live-e2e.test.sh # claude 2.1.241 (Claude Code): installed executable claude.exe reports comm='claude.exe' acceptance-kind='claude' ok - session-lock identity: claude 2.1.241 (Claude Code) is identified, refuses an unrelated session, and releases while suspended # codex codex-cli 0.139.0: comm='MainThread' argv='node .../bin/codex' acceptance-kind='none' ok - session-lock identity: codex codex-cli 0.139.0 is identified, refuses an unrelated session, and releases while suspended # reporter control 'claude.exe': comm='claude.exe' acceptance-kind='claude' (expected claude) # reporter control 'claude bg-pty-host': comm='claude bg-pty-h' acceptance-kind='claude' (expected claude) # reporter control 'claudette': comm='claudette' acceptance-kind='none' (expected none) # reporter control 'claude-code': comm='claude-code' acceptance-kind='none' (expected none) # reporter control 'node': comm='node' acceptance-kind='none' (expected none) # reporter control 'python3': comm='python3' acceptance-kind='none' (expected none) ok - session-lock identity: this machine's reported command names are typed after the reporter's own artifacts, and near misses still are not # unverified on this machine (not installed): opencode pi pi-signed grok kimi cursor # checked 2 installed harness(es) FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0Evidence: Portable regression suites through the repo test runner
Source: Portable regression suites through the repo test runner
FM_TEST_END tests/fm-session-lock-ancestry.test.sh exit=0 duration_ms=134408 FM_TEST_END tests/fm-turnend-guard.test.sh exit=0 duration_ms=25142 FM_TEST_END tests/fm-claude-stop-autoarm.test.sh exit=0 duration_ms=60909 FM_TEST_SUMMARY total=3 failed=0 skipped_gate=0 duration_ms=220614 (118 ok lines, 0 not ok)Evidence: Reproducible demo script used to produce the CLI transcript
Source: Reproducible demo script used to produce the CLI transcript
Evidence: Changed-test selection resolves at head
Source: Changed-test selection resolves at head
$ bin/fm-test-run.sh --list --changed --base 1cb900c rc=0 selected suites: 152 the suites that own this change, present in the selection: tests/fm-claude-stop-autoarm-live-e2e.test.sh tests/fm-claude-stop-autoarm.test.sh tests/fm-session-lock-ancestry.test.sh tests/fm-turnend-guard.test.sh tests/fm-session-lock-identity-live-e2e.test.shEvidence: Regression proof: the new turn-end fail-open case fails without the fix
Source: Regression proof: the new turn-end fail-open case fails without the fix
$ bash tests/fm-turnend-guard.test.sh # base bin/fm-turnend-guard.sh not ok - an absent auto-arm with an exhausted budget must reach the bounded attended fail-open: expected exit 0, got 2 exit=1 Same case on the branch head: ok - fm-turnend-guard --claude: an auto-arm that never claimed still reaches one bounded attended fail-openPipeline
Updates from git push no-mistakes
... (7 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)
🔧 Fix: order launch markers by process start time
1 info still open:
bin/fm-session-lock-lib.sh:799- The new start-time ordering is presented as excluding the recycled-pid case outright, while the comparison it rests on is deliberately tick-quantized and inclusive, so the narrowest form of the case it was added for survives.fm_session_marker_ordered(line 748) compares/proc/<pid>/statfield 22, which is USER_HZ clock ticks (100 Hz, so 10ms), with[ "$named" -le "$carrier" ]. The<=is correct and its header at 740-743 says why: a real launcher and the child it spawns can land on one tick, and refusing those would be a false refusal of a legitimate launch, which is defect 1's own failure class. What no surface says is the other half of that tolerance. In real time a recycled occupant always starts strictly after the carrier - the launcher held the pid at the carrier's exec, which is at or after the carrier's fork time, and the launcher must die before the pid can be reassigned - so the ONLY thing the check cannot separate is a recycled occupant whose creation lands in the same 10ms tick as the carrier's fork. In that casenamedequalscarrier, the relationship is accepted on numeric equality exactly as before, and co-location alone is again what stands between an unrelated session and the home. Two surfaces read as closed on it: line 799 says "a recycled occupant started after the process that carried the marker and so cannot be the launcher that marker names", which is true of reality but is being offered as what the check establishes; and docs/watcher-continuity.md:25 says the ordering requirement is one "which a recycled occupant cannot satisfy", which is a claim about the check and is not exactly true. docs/verification/runtime-backends.md:278 is already precise ("must also have started no later than") and needs nothing. NO code change: tightening to strict<would refuse legitimate same-tick launches, which is the defect this work exists to remove, and that trade is already settled in the header. Recommend stating the residual once where the tolerance is owned (lines 740-743) and making the two summaries defer to it rather than restating it, which is the same one-owner discipline round 3 applied to the read-race clause. Flagged because this task has now corrected five records that claimed a narrowing was complete when it was not, and the captain's standing instruction was that no surface read as closed.bin/fm-test-run.sh:997- bin/fm-test-run.sh --changed refused with exit 2 ("no changed-test mapping for source path: tests/session-signals.sh") on this branch, because the new shared test helper tests/session-signals.sh matches neither tests/lib.sh nor tests/-helpers.sh and fell through to the fail-closed tests/ arm. Fixed by registering it alongside tests/lib.sh so it resolves through families_for_test_reference; --changed --base <base> now returns a conservative selection at exit 0, and the runner's own "changed selection covers dependents and fails closed for unmapped source" case still passes.tests/fm-test-run.test.sh- tests/fm-test-run.test.sh cannot complete on this machine: its final case fails with "ruby is required to parse .github/workflows/ci.yml as YAML" and no ruby is installed here. The case is untouched by this branch and the environment gap is not fixable inside the worktree boundary (installing a system package is out of scope), so the 16 preceding cases (including every changed-file selection case) were verified and the YAML-parsing case is left to CI.bin/fm-test-run.sh tests/fm-session-lock-ancestry.test.sh tests/fm-turnend-guard.test.sh tests/fm-claude-stop-autoarm.test.sh- 111 cases, exit 0FM_SESSION_LOCK_IDENTITY_LIVE=1 bin/fm-test-run.sh tests/fm-session-lock-identity-live-e2e.test.sh- real installed claude 2.1.241 and codex 0.139.0 in private tmux ptys, plus the six reported-command-name controls, exit 0FM_CLAUDE_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-claude-stop-autoarm-live-e2e.test.sh- exit 0, competing-live-owner control intactManual before/after product demo with real processes:demo-session-lock-and-turnend.sh <worktree> 1cb900c 336505fruns bin/fm-lock.sh and bin/fm-turnend-guard.sh --claude from two full archives of the repo (base commit vs branch head) against identical fixturesDefect 1(a) manually reproduced: realclaudewindow holder in state/.lock, realclaude bg-pty-host(commclaude bg-pty-h) asking session orphaned into its own tree with CLAUDE_PID and a shared paneDefect 1(b) manually reproduced: realclaudeholder SIGSTOPped to state T, checked viabin/fm-lock.sh statusandbin/fm-lock.shSafety property manually checked: unrelated liveclaudeholder in a different pane with no launch relationship is still refused after the fix, and state/.lock is left naming that sessionDefect 2 manually reproduced: five consecutive--claudeturn-end hook invocations on a primary home with one task in flight, no watcher, and no state/.claude-autoarm-epochRegression proof that the new case fails before the fix: base bin/ + this branch's tests/ ->not ok - an absent auto-arm with an exhausted budget must reach the bounded attended fail-open: expected exit 0, got 2bin/fm-test-run.sh tests/fm-test-run.test.shafter editing the runner - 16 cases pass including the changed-file selection cases; final ruby/YAML case fails for a missing local interpreterbin/fm-test-run.sh --check-coverage- FM_TEST_COVERAGE ok total=152bin/fm-test-run.sh --list --changed --base 1cb900cbefore and after the changed-file map fix🔧 Fix: register tests/session-signals.sh in changed-test map
1 info still open:
tests/fm-test-run.test.sh- tests/fm-test-run.test.sh cannot finish on this machine: 16 cases pass, then the final case fails with "ruby is required to parse .github/workflows/ci.yml as YAML" and no ruby is installed here. The case is untouched by this branch and installing a system package is outside the worktree boundary, so the changed-file selection cases (which this branch does touch) were verified individually and the YAML-parsing case is left to CI.bash tests/fm-session-lock-ancestry.test.sh- 30 cases, 0 failuresbash tests/fm-turnend-guard.test.sh- 66 cases, 0 failuresbash tests/fm-claude-stop-autoarm.test.sh- 22 cases, 0 failuresbash bin/fm-test-run.sh tests/fm-session-lock-ancestry.test.sh tests/fm-turnend-guard.test.sh tests/fm-claude-stop-autoarm.test.sh- 118 ok, failed=0FM_SESSION_LOCK_IDENTITY_LIVE=1 bash bin/fm-test-run.sh tests/fm-session-lock-identity-live-e2e.test.sh- real claude 2.1.241 and codex 0.139.0 exercised; six reporter controls typed as expected; uninstalled harnesses reported explicitlybash bin/fm-test-run.sh tests/fm-claude-stop-autoarm-live-e2e.test.sh tests/fm-session-lock-identity-live-e2e.test.sh- both opt-in guards self-skip cleanly with gate_skip=true when their env gates are unsetManual end-to-end demo with real processes, run twice (base 1cb900c bin/ vs head d5eb54e bin/): defect 1(a) backgroundclaude bg-pty-hsession beside the window, defect 1(b) real SIGSTOPped holder, the separate-concurrent-session safety control, and five consecutivefm-turnend-guard.sh --claudeturn ends with no auto-arm evidencebash bin/fm-test-run.sh --list --changed --base 1cb900c- rc=0, 152 suites, includes all four suites owning this change (verifies the round-1 changed-test-map fix now committed as d5eb54e)bash tests/fm-test-run.test.sh- 16 ok, 1 pre-existing environment failure (no ruby on this machine)docs/configuration.md:518- The newFM_SESSION_STOP_SAMPLESandFM_SESSION_STOP_SAMPLE_SLEEPknobs in bin/fm-session-lock-lib.sh are environment-readable but are not added to docs/configuration.md's env-variable list. Judgment call: that list is a curated operator-tuning surface rather than an exhaustive inventory (dozens of internal FM_* variables are absent from it by design), and these two are fixture-speed knobs whose validation and fail-closed rationale are already owned by the function header. Adding them would grow the operator surface with an internal detail; if the captain wants them operator-visible, configuration.md is the owner to add them to.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.