Skip to content

fix(bin): decide session-lock ownership by session cohort and make the turn-end fail-open reachable - #2929

Open
adriantoczydlowski wants to merge 18 commits into
kunchenguid:mainfrom
adriantoczydlowski:fm/fm-session-lock-identity
Open

fix(bin): decide session-lock ownership by session cohort and make the turn-end fail-open reachable#2929
adriantoczydlowski wants to merge 18 commits into
kunchenguid:mainfrom
adriantoczydlowski:fm/fm-session-lock-identity

Conversation

@adriantoczydlowski

Copy link
Copy Markdown

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.

  • Round 3: a holder launched by this session and co-located with it stays the same cohort.
  • Round 4 (A2): for the MainThread script-path caller, stop scanning at the first flag, refuse to guess when a flag is present, and match a whole path component on the remaining tokens.
  • Round 5 (A): correct the record rather than narrowing the sibling bare-interpreter branch in that round; file the narrowing separately.
  • Round 6 (A): scope the launch-relationship check to the ASKING harness, so a marker row verified only for Claude is consulted only when both the asking session and the holder are Claude, with the asking-side kind read from the harness ancestry and not from the shell that inherits it, and an unresolvable kind falling back to ancestry-only. This closed a reproduced cross-harness violation where an inherited CLAUDE_PID let a codex process converge the lock onto itself.
  • Round 7 (C): apply the round-4 rule to the bare-interpreter branch too - skip the interpreter token, stop at the first flag, whole-path-component match on the rest, with both the harness flag and the kind derived from the matched token rather than from the whole argv string.
  • Round 8 (B) plus (A): the ACCEPTANCE authority for typing a live harness is STRICT EXECUTABLE IDENTITY - the command basename, or a whole path component of the command or argv[0]. Never a scanned argument list. The captain set the scope boundary explicitly: name recognition stays FULLY IN FORCE for fm_harness_ancestry_pids and fm_harness_pid_alive, because interpreting a recorded pid at all requires recognising a harness process by name; only ACCEPTANCE is narrowed. The pi widening introduced in round 7 was restored to the anchored ^pi$ behaviour FM_HARNESS_RE already declares.
  • Round 9 (a), the head under validation: normalise the reported command name before the SAME exact-equality test - take its first space-delimited word to undo the procps 15-character truncation, then strip the platform executable suffix - then compare exactly as before. Path components stay untouched and pi keeps its anchoring. This was required because the round-8 strict rule could not type the real install shape on this machine, which reopened defect 1(a): readlink -f of claude here ends at .../node_modules/@anthropic-ai/claude-code/bin/claude.exe, and the background worker shape appears truncated by procps as "claude bg-pty-h"; this machine had nine processes reporting claude and one reporting claude.exe, so the failing shape is live. The verified table: claude.exe, "claude bg-pty-h" and claude all type as claude, while claudette, claude-code, pineapple, node, python3 and MainThread still type as nothing. The rejected alternative was to accept a harness name followed by any non-name character, which would type claude-code as claude and contradict the npm-layout limit the captain instructed be STATED rather than fixed. Also folded into round 9: fm_harness_exec_kind was collapsed to delegate to fm_harness_basename_name instead of carrying a fourth hand-written copy of the same anchoring loop. The normalisation is applied to a reported COMMAND NAME and deliberately NOT to the MainThread script-path caller that shares the same exact-equality helper, because a script path carries neither the truncation nor the executable suffix, so extending it there would widen a rule for artifacts that cannot occur in it.
  • Defect 2: the fail-open opens on either an exhausted failure episode OR a verified ABSENT auto-arm.

DELIBERATE, CAPTAIN-ACCEPTED LIMITS - these are stated truthfully in the code comments and the verification record and must NOT be treated as new defects.

  • The launch-marker table reaches Claude only; codex, opencode, pi, pi-signed, grok, kimi and cursor stay ancestry-only. That is a stated limit, not a bug to fix by guessing an environment variable name.
  • The node/python bare-interpreter branch reads argv path components, so an unrelated node or python process whose argv carries a path component named exactly claude, codex, pi, grok or kimi can be classified as a harness for IDENTIFICATION purposes. This is pre-existing, documented as known-loose in the lib and in the verification record, and deliberately deferred by the captain as separate follow-up work. It cannot reach the cohort's acceptance decision, so it is an identification-quality limit rather than a takeover path.
  • "node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js" is not recognised by the path-component rule, because the component is claude-code and not claude. The captain instructed this npm-layout limit be STATED with its concrete false-refusal consequence rather than fixed by loosening the rule.

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.

  • A portable regression in tests/, with REAL processes and no harness, pinning the logic so CI enforces it everywhere. tests/fm-session-lock-ancestry.test.sh was extended rather than replaced, following the neighbouring tests/fm-turnend-guard.test.sh and tests/fm-watcher-lock.test.sh house pattern. It covers both live failures as cases - an owner outside the ancestry that is nonetheless the same session, and a stopped holder - drives the signals apart deliberately, asserts the verdict survives losing one, and asserts the divergence itself so a case cannot go quietly vacuous.
  • A live guard in the live-harness-optin family run through bin/fm-test-run.sh, env-gated and self-skipping, exercising every INSTALLED harness for real and failing with the harness and version named. It reports an absent harness explicitly rather than passing silently, and refuses a pass that checked nothing.
  • Both fixtures and the live verifier carry claude.exe and "claude bg-pty-h" as acceptance cases with claudette, claude-code, node and python3 as negative controls. This was required because every earlier fixture symlinked an exact name and so could not see a suffixed or truncated command at all, which is why nine review rounds missed the round-9 regression. Every new fixture was proven to FAIL against the unfixed head before being trusted.
  • One fixture trap is deliberately handled: fm_harness_pid_kind on a real process launched from a file named "claude bg-pty-host" types as claude even on the unfixed head, because ${args%% *} splits argv[0] at the space into /claude, which the path rule then matches. The test therefore asserts fm_harness_exec_kind on the observed comm in ISOLATION first, with no argv, and only then checks that the live-pid reading agrees. Removing that isolation makes the case vacuous.
  • Tests must exercise behaviour through an executable interface and must never assert implementation-source bytes.
  • The dated per-harness result is recorded in docs/verification/runtime-backends.md, pointing at the live guard as the command that refreshes it.

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.sh now 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_CONTAINERS pane/session ids or shared tty) both hold, with marker pairs ordered by /proc start 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, .exe suffix stripped) so claude.exe and the procps-truncated claude bg-pty-h type as claude while claude-code, claudette, node and python3 still type as nothing; pi/pi-signed keep their anchored matching. A new fm_harness_pid_suspended treats a durably stopped holder (confirmed over several samples) as reclaimable rather than a permanent owner.
  • The three ownership callers switched from raw liveness to the new fm_session_lock_holder_competes predicate: bin/fm-lock.sh, bin/fm-claude-stop-autoarm.sh and bin/fm-turnend-guard-cursor.sh. fm-lock.sh status reports 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.sh gained failopen_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 and docs: tests/fm-session-lock-ancestry.test.sh was 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-gated tests/fm-session-lock-identity-live-e2e.test.sh (registered in bin/fm-test-run.sh's live-harness-optin family) exercises every installed harness and reports absent ones explicitly; tests/session-signals.sh derives the cohort signal list from the lib tables for tests/lib.sh and both live guards. docs/verification/runtime-backends.md records the dated per-harness result and stated limits, with docs/watcher-continuity.md, docs/turnend-guard.md and 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."}


================ BEFORE (base 1cb900c28faf23fe23c9bb54e63f7c3b436ea096) ================

### DEFECT 1(a): a background session beside the window, one real session

--- state/.lock names the window session pid 1918872 (claude)

--- the background session it launched asks for the lock from pid 1918880 (claude bg-pty-h)
$ bin/fm-lock.sh
error: another live firstmate session holds the lock (pid 1918872); operate read-only until resolved
exit status: 1
state/.lock now: 1918872

### DEFECT 1(b): a suspended holder after a session resume

--- state/.lock names pid 1918955, in state 'T' (stopped) and still answering kill -0
$ bin/fm-lock.sh status
lock: held by live harness pid 1918955

--- the resumed session asks for the lock from pid 1918981 (claude)
$ bin/fm-lock.sh
error: another live firstmate session holds the lock (pid 1918955); operate read-only until resolved
exit status: 1
state/.lock now: 1918955

### SAFETY: a genuinely separate concurrent session is still refused

--- state/.lock names an unrelated live session pid 1919056 in pane demo-pane-other

--- a second, unrelated session in pane demo-pane-mine asks from pid 1919061 (claude)
$ bin/fm-lock.sh
error: another live firstmate session holds the lock (pid 1919056); operate read-only until resolved
exit status: 1
state/.lock now: 1919056
(the lock must still name the other session, 1919056)

### DEFECT 2: turn end with an auto-arm that never claimed this home

--- primary home, one task in flight, no watcher, state/.claude-autoarm-epoch absent

turn end 1 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


turn end 2 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


turn end 3 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


turn end 4 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


turn end 5 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


================ AFTER (branch head d5eb54ea6ab37ad361672d6e12c3269432dd6437) ================

### DEFECT 1(a): a background session beside the window, one real session

--- state/.lock names the window session pid 1919646 (claude)

--- the background session it launched asks for the lock from pid 1919654 (claude bg-pty-h)
$ 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 status: 0
state/.lock now: 1919654

### DEFECT 1(b): a suspended holder after a session resume

--- state/.lock names pid 1919910, in state 'T' (stopped) and still answering kill -0
$ bin/fm-lock.sh status
lock: held by SUSPENDED harness pid 1919910 (reclaimable: a stopped session is not holding this home)

--- the resumed session asks for the lock from pid 1919948 (claude)
$ bin/fm-lock.sh
lock acquired: harness pid 1919948 (took over from suspended harness pid 1919910)
exit status: 0
state/.lock now: 1919948

### SAFETY: a genuinely separate concurrent session is still refused

--- state/.lock names an unrelated live session pid 1920209 in pane demo-pane-other

--- a second, unrelated session in pane demo-pane-mine asks from pid 1920214 (claude)
$ bin/fm-lock.sh
error: another live firstmate session holds the lock (pid 1920209); operate read-only until resolved
exit status: 1
state/.lock now: 1920209
(the lock must still name the other session, 1920209)

### DEFECT 2: turn end with an auto-arm that never claimed this home

--- primary home, one task in flight, no watcher, state/.claude-autoarm-epoch absent

turn end 1 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


turn end 2 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


turn end 3 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


turn end 4 -> exit 0
{"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."}


turn end 5 -> exit 2
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


================ DONE ================
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=0

FM_TEST_BEGIN 2026-08-24T05:40:52Z tests/fm-session-lock-identity-live-e2e.test.sh family=live-harness-optin expected_gate_skip=optin-env
# claude 2.1.241 (Claude Code): installed executable claude.exe reports comm='claude.exe' acceptance-kind='claude'
# claude 2.1.241 (Claude Code): comm='claude' argv='/home/adriantoczydlowski/.nvm/versions/node/v24.16.0/bin/claude' acceptance-kind='claude'
# claude 2.1.241 (Claude Code): no descendant of a bare launch carried a launch marker verified for 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: executable identity names no harness, so the launch-marker accept path stays closed for it and its session-lock verdict is decided by process ancestry alone
# codex codex-cli 0.139.0: the installed executable codex.js is an interpreter script, so its own name never reaches a reported command name and only the launcher name is typed
# codex codex-cli 0.139.0: comm='MainThread' argv='node /home/adriantoczydlowski/.nvm/versions/node/v24.16.0/bin/codex' acceptance-kind='none'
# codex codex-cli 0.139.0: no descendant of a bare launch carried a launch marker verified for codex
ok - session-lock identity: codex codex-cli 0.139.0 is identified, refuses an unrelated session, and releases while suspended
# skip: opencode is not installed on this machine, so its session-lock identity is unverified here
# skip: pi is not installed on this machine, so its session-lock identity is unverified here
# skip: pi-signed is not installed on this machine, so its session-lock identity is unverified here
# skip: grok is not installed on this machine, so its session-lock identity is unverified here
# skip: kimi is not installed on this machine, so its session-lock identity is unverified here
# skip: cursor is not installed on this machine, so its session-lock identity is unverified here
# 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
# unchecked: the cross-harness kind distinctness that scopes launch markers needs two harnesses that name themselves by executable identity, and only 1 did here (kinds observed: claude:claude)
# unverified on this machine (not installed): opencode pi pi-signed grok kimi cursor
# checked 2 installed harness(es)
FM_TEST_END 2026-08-24T05:40:59Z tests/fm-session-lock-identity-live-e2e.test.sh exit=0 duration_ms=7403 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=7465
FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=1 duration_ms=7403 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-session-lock-identity-live-e2e.test.sh duration_ms=7403
Evidence: 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)

FM_TEST_BEGIN 2026-08-24T05:41:17Z tests/fm-session-lock-ancestry.test.sh family=watcher-wake-lock expected_gate_skip=none
ok - session-lock: a version-named Claude Code session is identified from its install path and argv[0]
ok - session-lock: ordinary script paths under a harness directory are not harness processes
ok - session-lock: a MainThread-named harness is identified from the one plain script token, and only from there
ok - session-lock: a bare interpreter is identified from the argv path components before the first flag
ok - session-lock: cohort acceptance is typed from executable identity and never from an argument list
ok - session-lock: the command names a real install reports are typed for acceptance, and near misses still are not
ok - session-lock: ownership stops at the first non-harness gap above the contiguous run
ok - session-lock: a live version-named session holding the lock is not mistaken for a stale owner
ok - session-lock e2e: a version-named session claims the home and arms supervision
ok - session-lock e2e: a session parented by a harness-named daemon claims the home and arms supervision
ok - session-lock e2e: a version-named session under a harness-named daemon keeps its own lock
ok - session-lock: a session rehosted outside its own process tree still owns its lock
ok - session-lock: a session still owns its home once the lock names the session it started
ok - session-lock: a different harness never believes a launch marker it merely inherited
ok - session-lock: a session named only in its argument list gets no launch-marker accept path
ok - session-lock: the same harness still converges on the identical fixture
ok - session-lock: co-location alone never grants ownership of another session's home
ok - session-lock: a launch relationship alone never grants ownership across containers
ok - session-lock: a dead holder pid is refused before its recorded identity is consulted
ok - session-lock: a recycled holder pid cannot satisfy a stale launch marker
ok - session-lock: marker ordering accepts a launcher that predates its carrier and refuses one that does not
ok - session-lock: a start time is read correctly when the command name contains spaces
ok - session-lock: the runtime container signal carries co-location with the terminals driven apart
ok - session-lock: the controlling terminal carries co-location with the containers driven apart
ok - session-lock: a suspended holder stops holding and holds again once it resumes
ok - session-lock: acquisition names a takeover from a suspended holder and converges the lock
ok - session-lock: reclaiming a live but unidentified holder names it instead of moving the lock silently
ok - session-lock: a holder that no longer exists is still reclaimed silently
ok - session-lock: a stop undone during confirmation is not a suspended holder
ok - session-lock: unusable stop-sampling settings fall back to the default and never release a live holder
FM_TEST_END 2026-08-24T05:43:32Z tests/fm-session-lock-ancestry.test.sh exit=0 duration_ms=134408 gate_skip=false
FM_TEST_BEGIN 2026-08-24T05:43:32Z tests/fm-turnend-guard.test.sh family=watcher-wake-lock expected_gate_skip=none
ok - fm_supervision_unhealthy: false with no state/*.meta at all
ok - fm_supervision_unhealthy: true with in-flight task and no beacon ever
ok - fm_supervision_unhealthy: true with in-flight task and a beacon far outside the grace window
ok - fm_supervision_unhealthy: false with in-flight task and a fresh beacon
ok - fm_supervision_status: FM_SUP_QUEUE_PENDING tracks state/.wake-queue
ok - fm_supervision_needed: X-mode relay poll needs supervision
ok - fm_supervision_unhealthy: source-only home needs supervision
ok - fm-turnend-guard: silent no-op with nothing in flight
ok - fm-turnend-guard: blocks when a fresh beacon has no live watcher lock
ok - fm-turnend-guard: non-Claude path blocks a source-only home
ok - fm-turnend-guard: blocks on a dead watcher lock even when the beacon is fresh
ok - fm-turnend-guard: silent no-op with a live watcher lock and fresh beacon
ok - fm-turnend-guard: healthy non-Claude harness paths ignore Claude episode contention
ok - fm-turnend-guard: blocks on a live watcher lock with an ancient beacon
ok - fm-turnend-guard: blocks with the exact required reason in the primary when unhealthy
ok - fm-turnend-guard: blocks from active FM_HOME state, not only repo-root state
ok - fm-turnend-guard: X-mode repair reason sources the cadence config
ok - fm-turnend-guard: X-mode-only supervision remains guarded in default mode
ok - fm-turnend-guard: ignores stale repo-root state when FM_HOME is set
ok - fm-turnend-guard: uses FM_STATE_OVERRIDE ahead of FM_HOME/state
ok - fm-turnend-guard: stop_hook_active=true always allows the stop (never blocks twice in one turn)
ok - fm-turnend-guard: blocks a blind turn end in a secondmate's own home (.fm-secondmate-home no longer excludes it)
ok - fm-turnend-guard: idle-by-default - silent in a secondmate home with nothing in flight
ok - fm-turnend-guard: stop_hook_active=true allows the stop in a secondmate home (never blocks twice in one turn)
ok - fm-turnend-guard: secondmate deferred-death recovery - silent while watched, forces re-arm once the watcher exits
ok - fm-turnend-guard: inert in a secondmate's own child worktree (linked git worktree) even when unhealthy
ok - fm-turnend-guard: blocks a blind turn end in a treehouse-leased LINKED secondmate home (marker force-include)
ok - fm-turnend-guard: an invalid (empty) marker cannot spoof inclusion; linked worktree stays exempt
ok - fm-turnend-guard: a non-ASCII marker cannot spoof inclusion; linked worktree stays exempt
ok - fm-turnend-guard: inert in a crewmate/scout task worktree (linked git worktree) even when unhealthy
ok - fm-turnend-guard: fails open (never blocks) when jq is missing
ok - fm-turnend-guard: silent no-op on empty stdin
ok - fm-turnend-guard: runs well under the generous timing margin (0s)
ok - fm-turnend-guard-grok: forces one explicitly marked same-session resume when the shared predicate blocks
ok - fm-turnend-guard-grok: legacy environment loop guard prevents a nested resume loop
ok - fm-turnend-guard-grok: native false delegates blocking feedback with zero resume processes
ok - fm-turnend-guard-grok: native true remains bounded and starts no resume process
ok - fm-turnend-guard-grok: both spellings are typed and camelCase has deterministic precedence
ok - fm-turnend-guard-grok: malformed, invalidly typed, and missing-prerequisite payloads start neither path
ok - fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded
ok - tracked .claude/settings.json entries: 5 inert under grok, the documented subagent exception still armed, all live under Claude
ok - .codex/hooks.json: Stop hook uses hook process root when payload cwd is outside
ok - .codex/hooks.json: Stop hook ignores nested git root guard scripts
ok - .opencode primary plugin: guard path is anchored to worktree, not directory
ok - .pi primary extension: no-tool and multi-tool runs each inject exactly one guard follow-up
ok - .pi primary extension: delivery failure resets the logical-run latch
ok - fm-turnend-guard --claude: re-blocks a loop-guarded stop while unhealthy and unclaimed (incident regression)
ok - fm-turnend-guard --claude: X-mode-only homes re-block when auto-arm recovery is absent
ok - fm-turnend-guard --claude: a live arming epoch advances once and repeated observation is idempotent
ok - fm-turnend-guard --claude: repeated failed-to-arming races make bounded monotonic progress
ok - fm-turnend-guard --claude: terminal owner boundary excludes a concurrent start without deadlock
ok - fm-turnend-guard --claude: fresh rewake epoch prevents a duplicate continuation for the same event
ok - fm-turnend-guard --claude: fresh failed epochs preserve and advance monotonic fail-open progression
ok - fm-turnend-guard --claude: integrated fresh failures reach one bounded fail-open, stop continuation, and reset on recovery
ok - fm-turnend-guard --claude: reset contention preserves all episode state until retry
ok - fm-turnend-guard --claude: concurrent auto-arm and guard resets are idempotent and deadlock-free
ok - fm-turnend-guard --claude: stale rewake epoch does not allow a blind stop
ok - fm-turnend-guard --claude: an auto-arm that never claimed still reaches one bounded attended fail-open
ok - fm-turnend-guard --claude: verified fail-open is loud, bounded, attended, and non-repeating
ok - fm-turnend-guard --claude: a fresh auto-arm entry keeps the fail-open shut
ok - fm-turnend-guard --claude: stale auto-arm evidence reaches the same bounded fail-open
ok - fm-turnend-guard --claude: an exhausted episode without its consumed notice keeps blocking
ok - fm-turnend-guard --claude: away ownership excludes the Stop-autoarm fail-open
ok - fm-turnend-guard --claude: positive watcher recovery resets failure episode state
ok - fm-turnend-guard --claude: bounded claim wait avoids a token-consuming forced continuation
ok - fm-turnend-guard --claude: secondmate home re-blocks unclaimed and allows auto-arm-claimed stops
FM_TEST_END 2026-08-24T05:43:57Z tests/fm-turnend-guard.test.sh exit=0 duration_ms=25142 gate_skip=false
FM_TEST_BEGIN 2026-08-24T05:43:57Z tests/fm-claude-stop-autoarm.test.sh family=unclassified expected_gate_skip=none
ok - auto-arm: inert in a linked child worktree even when in-flight
ok - auto-arm: inert with no session lock
ok - auto-arm: a demonstrably dead recorded session owner is reclaimed through fm-lock.sh before arming
ok - auto-arm: inert without arm, rewake, or lock replacement when another live harness owns the home
ok - auto-arm: inert while AFK owns supervision
ok - auto-arm: stale-owner recovery leaves the AFK and supervision-need gates unchanged
ok - auto-arm: resolves the outermost pid of a nested contiguous claude ancestry (bg-spare chain)
ok - auto-arm: inert with nothing in flight and no X-mode need
ok - auto-arm: actionable close translates to exactly one exit-2 rewake with reason
ok - auto-arm: actionable close survives a healthy successor without duplicate delivery
ok - auto-arm: bounded failure verification emits one automatic-mechanism alarm
ok - auto-arm: consecutive failures keep Stop-owned retry without repeating notice
ok - auto-arm: unverified clean close exhausts retries and fails closed
ok - auto-arm: post-alarm actionable outcomes cannot continue or reset failure state
ok - auto-arm: benign cycle end with a live watcher and fresh beacon stays silent across the next cycle
ok - auto-arm: budget contention preserves the episode and forces a reset retry
ok - auto-arm: X-mode poll need arms the cycle even with no tasks in flight
ok - auto-arm: concurrent firings admit one owner and one rewake translation
ok - auto-arm: need vanishing mid-cycle closes without a rewake
ok - auto-arm: mid-cycle AFK hands triage to the daemon with no rewake
ok - auto-arm: active in a marked secondmate home
ok - fm-lock: shared session-lock lib preserves the status path
FM_TEST_END 2026-08-24T05:44:58Z tests/fm-claude-stop-autoarm.test.sh exit=0 duration_ms=60909 gate_skip=false
FM_TEST_SUMMARY total=3 failed=0 skipped_gate=0 duration_ms=220614
FM_TEST_SUMMARY_FAMILY family=unclassified count=1 duration_ms=60909 failed=0
FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=2 duration_ms=159550 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-session-lock-ancestry.test.sh duration_ms=134408
FM_TEST_SLOWEST rank=2 script=tests/fm-claude-stop-autoarm.test.sh duration_ms=60909
FM_TEST_SLOWEST rank=3 script=tests/fm-turnend-guard.test.sh duration_ms=25142
Evidence: Reproducible demo script used to produce the CLI transcript

Source: Reproducible demo script used to produce the CLI transcript

Usage: demo-session-lock-and-turnend.sh <worktree> <base-commit> <head-commit>
Extracts two full copies of the repo scripts (base and head) and drives the real bin/fm-lock.sh and bin/fm-turnend-guard.sh --claude over real harness-named processes and a real SIGSTOPped holder. Nothing is stubbed.
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.sh

# bin/fm-test-run.sh --changed at branch head d5eb54e

$ 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.sh
Evidence: 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-open

# Regression proof: the new turn-end fail-open case fails without the fix

Tree: base commit 1cb900c bin/ + this branch's tests/ (and only bin/fm-session-lock-lib.sh
taken from the branch, because tests/session-signals.sh derives its cleared-signal list
from that library and cannot load the base copy at all).

$ bash tests/fm-turnend-guard.test.sh   # base bin/fm-turnend-guard.sh
ok - fm-turnend-guard --claude: concurrent auto-arm and guard resets are idempotent and deadlock-free
ok - fm-turnend-guard --claude: stale rewake epoch does not allow a blind stop
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:
$ bin/fm-test-run.sh tests/fm-turnend-guard.test.sh
ok - fm-turnend-guard --claude: an auto-arm that never claimed still reaches one bounded attended fail-open
- Outcome: ⚠️ 1 info across 2 runs (33m22s)

Pipeline

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.)

⚠️ **Review** - 1 info

🔧 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/&lt;pid&gt;/stat field 22, which is USER_HZ clock ticks (100 Hz, so 10ms), with [ &#34;$named&#34; -le &#34;$carrier&#34; ]. The &lt;= 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 case named equals carrier, 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 &lt; 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.
⚠️ **Test** - 1 info
  • ⚠️ 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 0
  • FM_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 0
  • FM_CLAUDE_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-claude-stop-autoarm-live-e2e.test.sh - exit 0, competing-live-owner control intact
  • Manual before/after product demo with real processes: demo-session-lock-and-turnend.sh &lt;worktree&gt; 1cb900c 336505f runs 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 fixtures
  • Defect 1(a) manually reproduced: real claude window holder in state/.lock, real claude bg-pty-host (comm claude bg-pty-h) asking session orphaned into its own tree with CLAUDE_PID and a shared pane
  • Defect 1(b) manually reproduced: real claude holder SIGSTOPped to state T, checked via bin/fm-lock.sh status and bin/fm-lock.sh
  • Safety property manually checked: unrelated live claude holder in a different pane with no launch relationship is still refused after the fix, and state/.lock is left naming that session
  • Defect 2 manually reproduced: five consecutive --claude turn-end hook invocations on a primary home with one task in flight, no watcher, and no state/.claude-autoarm-epoch
  • Regression 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 2
  • bin/fm-test-run.sh tests/fm-test-run.test.sh after editing the runner - 16 cases pass including the changed-file selection cases; final ruby/YAML case fails for a missing local interpreter
  • bin/fm-test-run.sh --check-coverage - FM_TEST_COVERAGE ok total=152
  • bin/fm-test-run.sh --list --changed --base 1cb900c before 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 failures
  • bash tests/fm-turnend-guard.test.sh - 66 cases, 0 failures
  • bash tests/fm-claude-stop-autoarm.test.sh - 22 cases, 0 failures
  • bash 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=0
  • FM_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 explicitly
  • bash 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 unset
  • Manual end-to-end demo with real processes, run twice (base 1cb900c bin/ vs head d5eb54e bin/): defect 1(a) background claude bg-pty-h session beside the window, defect 1(b) real SIGSTOPped holder, the separate-concurrent-session safety control, and five consecutive fm-turnend-guard.sh --claude turn ends with no auto-arm evidence
  • bash 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)
⚠️ **Document** - 1 info
  • ℹ️ docs/configuration.md:518 - The new FM_SESSION_STOP_SAMPLES and FM_SESSION_STOP_SAMPLE_SLEEP knobs 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.

Adrian Toczydlowski added 18 commits August 24, 2026 05:56
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.
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.
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (2): Last reviewed commit: "no-mistakes(document): widen FM_PROC_ROO..." | Re-trigger Greptile

@adriantoczydlowski
adriantoczydlowski force-pushed the fm/fm-session-lock-identity branch from dc52b37 to 96a879c Compare August 24, 2026 06:02
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Scheduled 11:10pm PT 8/23 pass. VISION.md read in full from current main 7b88520c055408a18f1476ecce08be60b2885fc9 (#2858). First look at this unstamped PR. No captain comment authorizing a merge. Author does not claim this supersedes covering #2437 (body never names #2437 / #2839 / #2883 / #2314).

VISION (inspected fm_session_lock_holder_competes / cohort AND of launch-marker + co-location in bin/fm-session-lock-lib.sh, acquire path still me=$(fm_harness_ancestry_pid) in bin/fm-lock.sh, auto-arm/turnend callers switching to holder_competes, and failopen_condition_verified / autoarm_never_established in bin/fm-turnend-guard.sh). Per-rule:

  • One captain, one interface: aligns (false "another session" lock refusal is a dishonest gate; reclaim of a live unidentified holder is now named on stdout rather than silent).
  • Authority is explicit and never inferred: mixed — stranger sessions still fail closed; suspended-holder reclaim and a reachable attended fail-open when auto-arm never claimed are deliberate widenings. Contributor "captain decisions from nine review rounds" are pipeline review rounds, not a word from this captain.
  • Scripts own the mechanics: aligns (identity, cohort, suspend sampling, fail-open predicates are deterministic).
  • A restart is a non-event: aligns (lock still a durable pid record; fail-open still bounded and alarmed).
  • Delegation with a spine: aligns (exclusive session authority is a refusal path).
  • The fleet outlives any vendor: mixed — cohort launch-marker table is Claude-only; other harnesses stay ancestry-only, stated as a limit. Container table has tmux/herdr/cmux pane ids; zellij/orca fall back.
  • Scope: does not align as a third lock-identity PR beside open covering fix(bin): let a session prove it owns its own fleet lock from a reparented pool #2437 and open fix(bin): recognize Claude session locks across worker pools #2839 on the same lock files.

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 .github / secrets / workflow injection. Trust-boundary notes from the full diff: cohort requires two AND-ed signals and falls back to ancestry rather than widening on missing evidence; unidentified live pids are treated as non-competing (reclaim announced). Sibling-pool write-path #2883 is not closed here — acquire still publishes fm_harness_ancestry_pid. First-time fork adriantoczydlowski (no merged PRs). Full diff reviewed before workflow approval; no security reason to withhold CI.

Workflow-approval this pass (captain gh):

  • 32695698604 CI — approved; now in_progress
  • 32695698635 Require no-mistakes — approved; completed success (GitHub body-compliance only)

CI / NM: HEAD 96a879c51613c2be289c2bbd60ced4091dba6a7e. MERGEABLE / UNSTABLE, ahead 18 / behind 0. Body no-mistakes-pipeline-attestation:v1 names dc52b37c0e98bec53f38f5c3e3ff5720a263c657, not THIS HEAD. GitHub NM SUCCESS is not a matching SHA.

Overlap / HOLD: covering #2437 still OPEN (sidecar state/.lock.session; same bin/fm-lock.sh + bin/fm-session-lock-lib.sh). #2839 still OPEN (CLAUDE_PID read-path). Do not land a third lock PR as auto. #2883 sibling-pool write-path remains open. Also bin/fm-test-run.sh. Not spawn/herdr holds.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants