From d592115ae13dbf9a6290a452c9d7c81e79d444ba Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Mon, 24 Aug 2026 13:28:29 +0100 Subject: [PATCH 01/11] test: reproduce the background-session session-lock identity divergence A daemon-hosted background session sits several harness-named hops below the session that launched it, with no non-harness process in between, so the whole chain reads as one contiguous harness run. Resolving this session's identity as the outermost pid of that run therefore records the LAUNCHING session's pid. When the daemon above the session later exits, that recorded pid stops being an ancestor, and because it still names a live harness the Stop-owned auto-arm reads it as a competing session and stays inert for the rest of the session. These cases build that real three-level process shape and drive the real bin/fm-lock.sh, the real Stop auto-arm, and the real turn-end guard through it, alongside the negative cases that must keep behaving as they do today: a live launching session that genuinely owns the home, a demonstrably dead owner, away mode, and an inherited session declaration. --- tests/fm-session-lock-ancestry.test.sh | 252 +++++++++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index d7ac74f3736..d3ff595c88f 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -356,6 +356,253 @@ test_e2e_daemon_parented_version_named_session_keeps_its_lock() { pass "session-lock e2e: a version-named session under a harness-named daemon keeps its own lock" } +# --- background-session layer: which pid the WRITER records ------------------- +# +# A daemon-hosted background session sits several harness-named hops below the +# session that launched it: session -> pty host -> daemon -> launching session, +# with no non-harness process anywhere in between. The whole chain therefore +# reads as one contiguous harness run, so resolving "this session" as the +# outermost pid of that run reaches past this session's own processes and lands +# on the launching session. The fixtures below build that real shape and drive +# the real bin/fm-lock.sh and the real Stop auto-arm through it. + +install_guard_scripts() { # + local dir=$1 + cp "$ROOT/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard.sh" + chmod +x "$dir/bin/fm-turnend-guard.sh" + # The guard shells out for its repair line and matches watcher identity by + # this path; neither decides anything these cases assert. + cat > "$dir/bin/fm-supervision-instructions.sh" <<'SH' +#!/usr/bin/env bash +printf 'arm supervision\n' +SH + cat > "$dir/bin/fm-watch.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-watch.sh" +} + +# A primary home plus the three-level launcher/daemon/session fixture. Each +# level runs through a real executable named "claude" so the ancestry walk sees +# a genuine contiguous harness run, and each records its own pid before doing +# anything else so bash cannot tail-exec-collapse two levels into one. +make_bg_session_home() { # + local dir=$1 + mkdir -p "$dir/state" + git init -q "$dir" + git -C "$dir" commit -q --allow-empty -m init + : > "$dir/AGENTS.md" + : > "$dir/state/task.meta" + install_autoarm_scripts "$dir" + install_guard_scripts "$dir" + + # The session a background job is launched FROM. It stays alive for the whole + # case, so its pid is always a live harness pid. + cat > "$dir/launcher.sh" <<'SH' +#!/usr/bin/env bash +i=0 +while [ "$i" -lt 400 ] && [ "$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' ')" != 1 ]; do + sleep 0.05 + i=$((i + 1)) +done +printf '%s\n' "$$" > "$FM_HOME/state/launcher-pid" +if [ "${FM_FIXTURE_LAUNCHER_TAKES_LOCK:-0}" = 1 ]; then + CLAUDE_PID=$$ "$FM_HOME/bin/fm-lock.sh" > "$FM_HOME/state/launcher-lock.out" 2>&1 + printf '%s\n' "$?" > "$FM_HOME/state/launcher-lock.rc" +fi +"$FM_CLAUDE_BIN" "$FM_HOME/daemon.sh" & +i=0 +while [ "$i" -lt 600 ] && [ ! -e "$FM_HOME/state/lock-done" ]; do + sleep 0.05 + i=$((i + 1)) +done +if [ "${FM_FIXTURE_LAUNCHER_EXITS:-0}" = 1 ]; then + : > "$FM_HOME/state/launcher-gone" + exit 0 +fi +i=0 +while [ "$i" -lt 900 ] && [ ! -e "$FM_HOME/state/finished" ]; do + sleep 0.05 + i=$((i + 1)) +done +SH + + # The shared daemon that hosts background sessions. It exits once the session + # has taken the lock, which is what severs the chain above the session and + # leaves the session's own recorded identity unreachable from its ancestry. + cat > "$dir/daemon.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/daemon-pid" +"$FM_CLAUDE_BIN" "$FM_HOME/session.sh" & +i=0 +while [ "$i" -lt 600 ] && [ ! -e "$FM_HOME/state/lock-done" ]; do + sleep 0.05 + i=$((i + 1)) +done +exit 0 +SH + + # The background session itself: session start first, then the Stop hooks + # after the daemon above it has gone. + cat > "$dir/session.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/session-pid" +# The harness names the session process to everything it spawns; the fixture +# stands in for that. FM_FIXTURE_STALE_DECLARATION models an inherited value +# from the launching session instead of a per-session one. +if [ "${FM_FIXTURE_STALE_DECLARATION:-0}" = 1 ]; then + export CLAUDE_PID=$(cat "$FM_HOME/state/launcher-pid") +else + export CLAUDE_PID=$$ +fi +"$FM_HOME/bin/fm-lock.sh" > "$FM_HOME/state/session-lock.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/session-lock.rc" +cp "$FM_HOME/state/.lock" "$FM_HOME/state/lock-after-start" 2>/dev/null +: > "$FM_HOME/state/lock-done" +i=0 +while [ "$i" -lt 600 ] && [ "$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' ')" != 1 ]; do + sleep 0.05 + i=$((i + 1)) +done +if [ "${FM_FIXTURE_LAUNCHER_EXITS:-0}" = 1 ]; then + i=0 + while [ "$i" -lt 600 ] && [ ! -e "$FM_HOME/state/launcher-gone" ]; do + sleep 0.05 + i=$((i + 1)) + done +fi +"$FM_HOME/bin/fm-claude-stop-autoarm.sh" "$FM_HOME/state/hook.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/hook.rc" +printf '%s' '{"session_id":"fixture","stop_hook_active":false}' \ + | "$FM_HOME/bin/fm-turnend-guard.sh" --claude > "$FM_HOME/state/guard.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/guard.rc" +: > "$FM_HOME/state/finished" +SH + chmod +x "$dir/launcher.sh" "$dir/daemon.sh" "$dir/session.sh" +} + +# Start the fixture detached, so the launcher itself is orphaned and the walk +# can never climb out of the fixture into the session running this suite. +run_bg_session_tree() { # [...] + local dir=$1 i + shift + env FM_HOME="$dir" FM_CLAUDE_BIN="$NAMED_CLAUDE" "$@" \ + bash -c '"$0" "$1" &' "$NAMED_CLAUDE" "$dir/launcher.sh" + i=0 + while [ "$i" -lt 900 ] && [ ! -e "$dir/state/finished" ]; do + sleep 0.05 + i=$((i + 1)) + done + [ -e "$dir/state/finished" ] || fail "the background-session fixture never finished" +} + +fixture_pid() { # + tr -d '[:space:]' < "$1/state/$2" +} + +assert_distinct_chain() { # + local dir=$1 launcher daemon session + launcher=$(fixture_pid "$dir" launcher-pid) + daemon=$(fixture_pid "$dir" daemon-pid) + session=$(fixture_pid "$dir" session-pid) + [ -n "$launcher" ] && [ -n "$daemon" ] && [ -n "$session" ] \ + && [ "$launcher" != "$daemon" ] && [ "$daemon" != "$session" ] && [ "$launcher" != "$session" ] \ + || fail "fixture did not produce three distinct harness levels: launcher=$launcher daemon=$daemon session=$session" +} + +test_bg_session_records_its_own_identity_and_keeps_arming() { + local dir launcher session recorded + dir="$TMP_ROOT/bg-session-sole" + make_bg_session_home "$dir" + run_bg_session_tree "$dir" + assert_distinct_chain "$dir" + launcher=$(fixture_pid "$dir" launcher-pid) + session=$(fixture_pid "$dir" session-pid) + recorded=$(fixture_pid "$dir" lock-after-start) + + [ "$recorded" != "$launcher" ] \ + || fail "session start recorded the LAUNCHING session's pid $launcher as this session's identity" + [ "$recorded" = "$session" ] \ + || fail "session start recorded '$recorded' as this session's identity, expected the session pid $session" + expect_code 2 "$(hook_rc "$dir")" \ + "a background session that owns its home must claim it and rewake after the daemon above it has gone" + [ -e "$dir/state/arm-ran" ] \ + || fail "supervision never armed for a background session that owns its home" + [ "$(epoch_outcome "$dir")" = rewake ] \ + || fail "no claim was recorded for a background session, got: $(epoch_outcome "$dir")" + pass "session-lock: a background session records its own identity and keeps claiming its home" +} + +test_bg_session_never_claims_a_home_a_live_session_owns() { + local dir launcher recorded + dir="$TMP_ROOT/bg-session-competing" + make_bg_session_home "$dir" + run_bg_session_tree "$dir" FM_FIXTURE_LAUNCHER_TAKES_LOCK=1 + assert_distinct_chain "$dir" + launcher=$(fixture_pid "$dir" launcher-pid) + recorded=$(fixture_pid "$dir" lock-after-start) + + expect_code 1 "$(fixture_pid "$dir" session-lock.rc)" \ + "a background session must be refused the lock a live launching session already holds" + grep -q 'another live firstmate session holds the lock' "$dir/state/session-lock.out" \ + || fail "the refusal did not name the competing live session: $(cat "$dir/state/session-lock.out")" + [ "$recorded" = "$launcher" ] \ + || fail "the live owner's lock was overwritten: expected $launcher, got $recorded" + expect_code 0 "$(hook_rc "$dir")" "a session that does not own the home must stay inert" + [ ! -e "$dir/state/arm-ran" ] || fail "a session that does not own the home armed supervision" + [ -z "$(epoch_outcome "$dir")" ] || fail "a non-owning session wrote an auto-arm claim" + pass "session-lock: a background session never claims a home a live launching session owns" +} + +test_bg_session_recovers_a_genuinely_dead_owner() { + local dir session recorded + dir="$TMP_ROOT/bg-session-stale" + make_bg_session_home "$dir" + run_bg_session_tree "$dir" FM_FIXTURE_LAUNCHER_TAKES_LOCK=1 FM_FIXTURE_LAUNCHER_EXITS=1 + assert_distinct_chain "$dir" + session=$(fixture_pid "$dir" session-pid) + recorded=$(tr -d '[:space:]' < "$dir/state/.lock") + + expect_code 2 "$(hook_rc "$dir")" "a demonstrably dead owner must be reclaimed and the home claimed" + [ -e "$dir/state/arm-ran" ] || fail "supervision never armed after reclaiming a dead owner" + [ "$recorded" = "$session" ] \ + || fail "the reclaimed lock does not name the recovering session: expected $session, got $recorded" + pass "session-lock: a background session still reclaims a genuinely dead owner" +} + +test_bg_session_stays_inert_while_away_mode_owns_supervision() { + local dir + dir="$TMP_ROOT/bg-session-afk" + make_bg_session_home "$dir" + : > "$dir/state/.afk" + run_bg_session_tree "$dir" + expect_code 0 "$(hook_rc "$dir")" "away mode must keep the auto-arm inert" + [ ! -e "$dir/state/arm-ran" ] || fail "the auto-arm armed supervision while away mode owned it" + [ -z "$(epoch_outcome "$dir")" ] || fail "the auto-arm claimed the home while away mode owned it" + pass "session-lock: away mode still owns supervision for a background session" +} + +test_bg_session_falls_back_when_no_per_session_identity_is_declared() { + local dir launcher recorded + dir="$TMP_ROOT/bg-session-stale-declaration" + make_bg_session_home "$dir" + run_bg_session_tree "$dir" FM_FIXTURE_STALE_DECLARATION=1 + assert_distinct_chain "$dir" + launcher=$(fixture_pid "$dir" launcher-pid) + recorded=$(fixture_pid "$dir" lock-after-start) + + # An inherited declaration naming the launching session is indistinguishable + # from a genuine one, so identity falls back to the contiguous run's outermost + # pid. This case pins that fallback as unchanged prior behavior rather than a + # silent new failure mode: the recorded owner is the launching session, and the + # auto-arm consequently stays inert. + [ "$recorded" = "$launcher" ] \ + || fail "an inherited declaration did not fall back to prior behavior: got '$recorded', expected $launcher" + expect_code 0 "$(hook_rc "$dir")" "the documented fallback keeps the hook inert, not arming blind" + pass "session-lock: an inherited session declaration falls back to the prior identity unchanged" +} + test_version_named_session_is_identified_on_both_platforms test_ordinary_paths_are_never_harness_processes test_harness_beyond_a_gap_never_owns_the_lock @@ -363,3 +610,8 @@ test_competing_version_named_session_is_seen_as_live test_e2e_version_named_session_claims_the_home test_e2e_daemon_parented_session_claims_the_home test_e2e_daemon_parented_version_named_session_keeps_its_lock +test_bg_session_records_its_own_identity_and_keeps_arming +test_bg_session_never_claims_a_home_a_live_session_owns +test_bg_session_recovers_a_genuinely_dead_owner +test_bg_session_stays_inert_while_away_mode_owns_supervision +test_bg_session_falls_back_when_no_per_session_identity_is_declared From a17365a1251274651eabb5242cce2bc0a9a75112 Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Mon, 24 Aug 2026 13:31:43 +0100 Subject: [PATCH 02/11] fix(bin): record the harness-declared session pid as the lock identity Under a session-hosting daemon, two different arrangements produce an identical process chain: an async hook or tool call run for session X inside a daemon-hosted worker, and a background session launched BY X through that same daemon. No process-table fact separates them, so resolving identity as the outermost pid of the contiguous harness run records X in both - which is right for the worker and wrong for the background session. Prefer the harness's own declaration of which process is this session, accepted only when it names a live harness process inside this contiguous run. Membership is what makes an untrustworthy value harmless: a pid inherited from an unrelated session is not in this ancestry and is ignored, and a dead one cannot be recorded as a live owner. Everything else falls back to the outermost pid unchanged, which is what every harness that declares nothing keeps using. The self-ownership predicate is deliberately untouched. It already accepts the recorded pid anywhere in the ancestry; the defect was that the pid it was given only belonged to the session transiently. --- bin/fm-session-lock-lib.sh | 66 +++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index d77e563f0b4..38a7aef33a8 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Shared session-lock harness identity. # -# ONE owner of the "which verified-harness process holds this home's session -# lock, and does the current process descend from that same harness?" decision. +# ONE owner of the "which process identifies this session, which verified-harness +# process holds this home's session lock, and does the current process descend +# from that same harness?" decision. # bin/fm-lock.sh uses it to acquire and inspect state/.lock; # bin/fm-claude-stop-autoarm.sh uses it to prove a Stop hook fires inside the # lock-owning primary session before it may arm or rewake. @@ -125,15 +126,64 @@ fm_harness_ancestry_pids() { [ "$printed" -eq 1 ] } +# The harness's own statement of which process IS this session, or return 1. +# +# Ancestry alone cannot answer that question under a session-hosting daemon, +# because two different arrangements produce the identical process chain: +# - an async hook or tool call of session X, run for X inside a daemon-hosted +# worker (the pid to record is X, several hops up), and +# - a background session of its own, launched BY session X through that same +# daemon (the pid to record is the background session, several hops down). +# In both, every hop from the caller up to X is harness-named with no gap, so no +# process-table fact separates them. Claude Code does separate them: it exports +# CLAUDE_PID into the processes it spawns for a session, set to that session's +# own pid, overriding whatever value those processes inherited. A stale inherited +# value is therefore possible only where the harness did not spawn the process at +# all (a tmux server started from a session, say, and every pane below it), which +# is why the value is trusted only after the checks in the caller below. +fm_harness_declared_session_pid() { + local declared=${CLAUDE_PID:-} + case "$declared" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$declared" +} + # Print the one pid that identifies this session when the session lock is being -# WRITTEN: the outermost pid of the contiguous run. That is the pid that lives as -# long as the session - a Claude worker several levels in is reaped when its hook -# returns, and a lock naming it would look stale moments later while the session -# is still running. Every non-Claude harness reports a single pid, so this is its -# innermost match unchanged. +# WRITTEN. +# +# The harness's own declaration wins when it names a live harness process inside +# this contiguous run. Requiring membership is what makes an untrustworthy value +# harmless: an inherited pid from an unrelated session is not in this ancestry +# and is ignored, and a dead one cannot be recorded as a live owner. +# +# Otherwise fall back to the outermost pid of the contiguous run. That is the pid +# that lives as long as the session for every harness that declares nothing - a +# Claude worker several levels in is reaped when its hook returns, and a lock +# naming it would look stale moments later while the session is still running. +# Every non-Claude harness reports a single pid, so this is its innermost match +# unchanged. +# +# The fallback is also what the outermost pid costs: under a session-hosting +# daemon it reaches past this session's own processes onto the session that +# launched it, so the lock records a pid that is only transiently an ancestor. +# When the daemon between them exits, that pid stops being an ancestor while +# still naming a live harness, and fm_session_lock_owned_by_self below then +# reads this session's own lock as a competing session's - permanently, for the +# rest of the session. tests/fm-session-lock-ancestry.test.sh drives that shape. fm_harness_ancestry_pid() { - local pids pid outermost='' + local pids pid outermost='' declared pids=$(fm_harness_ancestry_pids) || return 1 + if declared=$(fm_harness_declared_session_pid) && fm_harness_pid_alive "$declared"; then + while IFS= read -r pid; do + if [ "$pid" = "$declared" ]; then + printf '%s\n' "$declared" + return 0 + fi + done < Date: Mon, 24 Aug 2026 13:37:40 +0100 Subject: [PATCH 03/11] test(bin): add the opt-in guard for the harness session declaration The writer now depends on a vendor-controlled surface: if Claude Code stopped setting CLAUDE_PID, or started letting an inherited value through, identity would silently fall back to the launching session's pid and a background session's supervision would go inert mid-session again. A stub agent cannot see that. The guard launches a real session with a deliberately wrong CLAUDE_PID planted in its environment and asserts the value reported inside that session's own hook is its own live process instead, so it proves the declaration is authoritative rather than merely present. It self-skips without the opt-in, refuses a pass that checked nothing, and fails naming the harness and version. Also records the dated result and re-selects the families a change to the identity library must re-run. --- bin/fm-lock.sh | 9 +- bin/fm-test-run.sh | 11 ++ docs/verification/runtime-backends.md | 1 + docs/verification/supervision.md | 20 ++++ docs/watcher-continuity.md | 1 + ...-session-lock-declaration-live-e2e.test.sh | 108 ++++++++++++++++++ 6 files changed, 147 insertions(+), 3 deletions(-) create mode 100755 tests/fm-session-lock-declaration-live-e2e.test.sh diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..16945dab68a 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash # Acquire or inspect the per-home firstmate session lock. -# Writes the harness (agent) process PID found by walking the shell's ancestry, -# which lives as long as the firstmate session - unlike the transient subshell -# PID of any one tool call, which is dead moments after it is written. +# Writes the PID that identifies this session, resolved by +# bin/fm-session-lock-lib.sh - which owns that decision, including why the +# harness's own declaration of its session process is preferred over the +# ancestry walk where one exists. Either way it is a process that lives as long +# as the firstmate session, unlike the transient subshell PID of any one tool +# call, which is dead moments after it is written. # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 2d3a64bcadb..7c18cdc324f 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -195,6 +195,7 @@ family_for_basename() { fm-herdr-version-floor-live-e2e.test.sh|\ fm-opencode-primary-live-e2e.test.sh|fm-pi-branch-live-e2e.test.sh|\ fm-pi-primary-live-e2e.test.sh|\ + fm-session-lock-declaration-live-e2e.test.sh|\ fm-sessionstart-hook-live-e2e.test.sh|fm-sessionstart-instruction-refresh-live-e2e.test.sh|\ fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh|\ fm-send-inbox-doorbell-live-e2e.test.sh|\ @@ -466,6 +467,7 @@ tests/fm-send-resolve-key.test.sh 13450 tests/fm-send-secondmate-marker-herdr-e2e.test.sh 45 tests/fm-send-secondmate-marker.test.sh 4439 tests/fm-session-lock-ancestry.test.sh 1205 +tests/fm-session-lock-declaration-live-e2e.test.sh 21 tests/fm-session-start.test.sh 144836 tests/fm-sessionstart-hook-live-e2e.test.sh 21 tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh 21 @@ -966,6 +968,15 @@ families_for_changed_path() { bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh) printf '%s\n' session-bootstrap ;; + bin/fm-session-lock-lib.sh) + # Session-lock identity: the writer at session start (session-bootstrap), + # the Stop auto-arm's self-ownership check (watcher-wake-lock), and the + # harness-declared session pid the writer prefers, which only a real + # harness can prove (live-harness-optin). + printf '%s\n' session-bootstrap + printf '%s\n' watcher-wake-lock + printf '%s\n' live-harness-optin + ;; bin/fm-sessionstart-run.sh|.claude/settings.json|.codex/hooks.json|\ .pi/extensions/fm-primary-turnend-guard.ts) # The run tier's two harness-supplied facts (source vocabulary and diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 0b41062a155..5e09e1f95e2 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -79,6 +79,7 @@ alive `#{pane_current_command}` and foreground `ps -o comm=` read different name fields, but which one preserves executable identity is platform-dependent. On macOS the pane command reflected the rewritable title while the full install path could survive in `ps -o comm=`; in the Linux portable regression those roles reversed for the version-named native executable, with the identifying path retained in argv[0]. The classifier therefore accepts a harness basename first, then an exact harness path component in the full executable path, then the same component in argv[0], without depending on which field carries it on a given platform. +Process identity is not the same question as which process IS the session; [`supervision.md`](supervision.md) owns that evidence and its own opt-in drift guard. The portable regression is CI-enforced, while the real-harness drift guard is opt-in under the policy in `.agents/skills/firstmate-coding-guidelines/SKILL.md`. Run the live guard after any harness upgrade and before trusting or refreshing the table above: diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 8ae889f30fe..e745a18f3c0 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -292,6 +292,26 @@ The current Stop-owned main/secondmate inclusion and child-worktree exclusion ar Session-lock ownership in `bin/fm-session-lock-lib.sh` is decided against a session's whole contiguous harness ancestry rather than one chosen pid, so the Stop auto-arm reaches its lock owner wherever that owner sits: the outermost pid of Claude Code's multi-level `bg-spare` hook worker chain, or an inner pid when a harness-named daemon parents the session. Harness identity is read from the executable path and `argv[0]` as well as the command basename, because Claude Code's native installer names the per-session executable by its version (`.../share/claude/versions/2.1.220`): `ps -o comm=` reports that path on macOS and the bare version string on Linux, and neither basename names a harness. `tests/fm-session-lock-ancestry.test.sh` pins both platforms' reporting semantics behind a deterministic process table and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees. + +Which pid the lock RECORDS is a separate decision from how ownership is checked, and under a session-hosting daemon the process table cannot settle it. +An async hook running for session X inside a daemon-hosted worker and a background session launched by X through that same daemon produce the identical chain: harness-named end to end, with no non-harness process between the caller and X. +Resolving identity as the outermost pid of that chain is right for the worker and wrong for the background session, which then records X and, once the daemon between them exits, can no longer recognize its own lock while X still names a live harness. +`bin/fm-session-lock-lib.sh` therefore prefers the harness's own declaration of its session process, accepted only when it names a live harness inside this contiguous run, and falls back to the outermost pid otherwise. +`tests/fm-session-lock-ancestry.test.sh` drives that three-level launcher/daemon/session shape with real processes, the real `bin/fm-lock.sh`, the real Stop auto-arm, and the real turn-end guard, alongside the live-owner, dead-owner, away-mode, and inherited-declaration cases. + +Measured with Claude Code 2.1.241 on 2026-08-24: a real session launched with a deliberately wrong `CLAUDE_PID=2147483646` in its environment reported its OWN process to its own `SessionStart` hook, and the writer recorded that pid. +The declaration is authoritative rather than inherited, which is the property the preference depends on. +Run the opt-in guard after every Claude Code upgrade and before trusting this result: + +```sh +FM_SESSION_LOCK_DECLARATION_DRIFT=1 bin/fm-test-run.sh tests/fm-session-lock-declaration-live-e2e.test.sh +``` + +```text +# claude: 2.1.241 (Claude Code) (/Users/tom/.local/bin/claude) +# declared session pid 26882 overrode the planted 2147483646; ancestry was: 26882 +ok - session-lock: claude 2.1.241 (Claude Code) declares its own session process to the processes it spawns, and the writer records it +``` `tests/fm-watch-arm.test.sh` runs real watcher and arm cycles against durable on-disk state to verify that a delivered reason survives until post-handling acknowledgement and stops replaying after acknowledgement, while an unrelated queue append cannot make a watcher cycle that delivered nothing look successful. The same suite ingests a keyed remote-secondmate parent reply through the real adapter, establishes the incremental OPEN DECISIONS cursor, interrupts supervision, and proves re-arm replays every unacknowledged queue row plus the still-open decision through the ordinary drain path. It also covers decision-only recovery, interrupted handling, handling-window generation reuse, non-fatal moved-generation acknowledgement with sequence-bounded consumption, and a persistent successor remaining live after recovery is acknowledged. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 26738628fea..7b33ec01193 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -13,6 +13,7 @@ Cursor's `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) own Claude's `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns routine tokenless re-arm. The hook fires on every Stop, and an eligible primary with supervision need admits one home-scoped owner that foregrounds `bin/fm-watch-arm.sh` inside the hook-owned process tree. A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner, absent lock, or malformed lock keeps the competing hook inert. +That inertness is correct only because the recorded owner is this session's own process rather than a pid it merely descends from, which is what `bin/fm-session-lock-lib.sh` owns for a session running under a session-hosting daemon. The stale-owner claim occurs only after the existing AFK and supervision-need gates pass. After each non-actionable arm close, the hook rechecks the identity-matched watcher lock and fresh beacon before retrying a bounded number of times. A cycle-end failure is benign when that live-watcher predicate is true, and the hook suppresses the arm output and continues silently. diff --git a/tests/fm-session-lock-declaration-live-e2e.test.sh b/tests/fm-session-lock-declaration-live-e2e.test.sh new file mode 100755 index 00000000000..ad55ba70423 --- /dev/null +++ b/tests/fm-session-lock-declaration-live-e2e.test.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# tests/fm-session-lock-declaration-live-e2e.test.sh - opt-in drift guard proving +# a real Claude Code session still declares its own process to the processes it +# spawns, which is what bin/fm-session-lock-lib.sh records as the session-lock +# identity. +# +# Why this file exists: under a session-hosting daemon the process table cannot +# tell an async hook running FOR a session apart from a background session +# launched BY it - both chains are harness-named end to end with no gap. The only +# signal that separates them is the harness's own declaration, so the writer +# prefers it. That makes a vendor-controlled surface load-bearing: if Claude Code +# stopped setting CLAUDE_PID, or started letting an inherited value through, the +# writer would silently fall back to recording the launching session's pid again +# and a background session's supervision would go inert mid-session. +# +# The guard therefore asserts the property that actually matters, not just that +# the variable exists: the session is launched with a DELIBERATELY WRONG +# CLAUDE_PID in its environment, and the value the harness reports inside its own +# SessionStart hook must be the session's own live process instead. A stub agent +# cannot prove that; only the real harness can. +# +# Standard CI has no harness binary and no credentials, so this is opt-in and +# on-demand. tests/fm-session-lock-ancestry.test.sh pins the same logic portably +# in CI with real processes and no harness. Run this guard after every Claude Code +# upgrade and before trusting refreshed evidence in +# docs/verification/runtime-backends.md. +# +# It runs one print-mode turn with a one-word prompt, which is the smallest real +# session that fires a hook. That token cost is deliberate: the alternative is a +# check that can only confirm the assumption already written into its own stub. +set -u + +if [ "${FM_SESSION_LOCK_DECLARATION_DRIFT:-0}" != 1 ]; then + echo "skip: set FM_SESSION_LOCK_DECLARATION_DRIFT=1 to run the installed-harness session-declaration drift guard" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +LAB= +cleanup_all() { [ -n "${LAB:-}" ] && rm -rf "$LAB"; } +fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +note() { printf '# %s\n' "$1"; } + +CLAUDE_BIN=$(command -v claude 2>/dev/null || true) +[ -n "$CLAUDE_BIN" ] && [ -x "$CLAUDE_BIN" ] \ + || fail "claude is not installed, so this guard verified nothing; install it or run the portable counterpart instead" + +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-session-lock-declaration.XXXXXX") || fail "could not create the lab directory" +trap cleanup_all EXIT + +VERSION=$("$CLAUDE_BIN" --version 2>/dev/null | head -1) +note "claude: ${VERSION:-unknown version} ($CLAUDE_BIN)" + +# A pid no live process can hold, planted in the launch environment. The harness +# must override it; anything that lets it through is the drift this guard exists +# to catch. +POISON=2147483646 +mkdir -p "$LAB/.claude" "$LAB/out" + +cat > "$LAB/report.sh" < "$LAB/out/report.txt" 2>&1 +exit 0 +SH +chmod +x "$LAB/report.sh" + +cat > "$LAB/.claude/settings.json" <"$LAB/out/turn.log" 2>&1 ) || true + +[ -s "$LAB/out/report.txt" ] \ + || fail "the real session never ran its own SessionStart hook, so nothing was verified: $(tail -3 "$LAB/out/turn.log" 2>/dev/null)" + +read_field() { sed -n "s/^$1=//p" "$LAB/out/report.txt" | head -1; } +DECLARED=$(read_field declared) +LIB_DECLARED=$(read_field lib_declared) +WRITER=$(read_field writer_identity) +ANCESTRY=$(read_field ancestry) + +case "$DECLARED" in + ''|none|*[!0-9]*) + fail "claude ${VERSION:-?} no longer declares a numeric session pid to its own hooks (got '$DECLARED'); the session-lock writer has silently fallen back to the launching session's pid" + ;; +esac +[ "$DECLARED" != "$POISON" ] \ + || fail "claude ${VERSION:-?} passed an INHERITED CLAUDE_PID through to its own hook instead of declaring the session; the writer can no longer tell a background session from a worker of the session above it" +[ "$LIB_DECLARED" = "$DECLARED" ] \ + || fail "the library read '$LIB_DECLARED' where the harness declared '$DECLARED'" +[ "$WRITER" = "$DECLARED" ] \ + || fail "claude ${VERSION:-?}: the session-lock writer recorded '$WRITER' instead of the declared session pid '$DECLARED'; ancestry was: $ANCESTRY" +case " $ANCESTRY " in + *" $DECLARED "*) : ;; + *) fail "claude ${VERSION:-?}: the declared session pid '$DECLARED' was not in the hook's own harness ancestry ($ANCESTRY), so the writer's membership check rejected it and fell back" ;; +esac + +note "declared session pid $DECLARED overrode the planted $POISON; ancestry was: $ANCESTRY" +pass "session-lock: claude ${VERSION:-?} declares its own session process to the processes it spawns, and the writer records it" From 8981b09e0e89bbe53ce7b9952c04140aae5cf868 Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Mon, 24 Aug 2026 13:45:15 +0100 Subject: [PATCH 04/11] test: cover the daemon-hosted shape in the session-declaration guard A print-mode session runs its hook directly beneath itself, so it cannot show what the writer would have recorded without the declaration. A real background agent can: its hook sits two harness-named hops below the shared session-hosting daemon, and the outermost pid of that chain is the daemon itself - a process every background session in the home shares and which outlives any one of them. The guard now exercises both shapes, refuses to pass if the background chain turned out to be a single hop (which would prove nothing about the case the fix exists for), reports the pid the old resolution would have recorded, and stops the agent it started. --- ...-session-lock-declaration-live-e2e.test.sh | 145 +++++++++++++----- 1 file changed, 105 insertions(+), 40 deletions(-) diff --git a/tests/fm-session-lock-declaration-live-e2e.test.sh b/tests/fm-session-lock-declaration-live-e2e.test.sh index ad55ba70423..75b4afb1d7e 100755 --- a/tests/fm-session-lock-declaration-live-e2e.test.sh +++ b/tests/fm-session-lock-declaration-live-e2e.test.sh @@ -25,9 +25,17 @@ # upgrade and before trusting refreshed evidence in # docs/verification/runtime-backends.md. # -# It runs one print-mode turn with a one-word prompt, which is the smallest real -# session that fires a hook. That token cost is deliberate: the alternative is a -# check that can only confirm the assumption already written into its own stub. +# Two real sessions are exercised, because they are different shapes and only the +# second is the one the fix exists for: +# - a print-mode session, whose hook runs directly under it, and +# - a real background agent (claude --bg), whose hook runs several harness-named +# hops below the shared session-hosting daemon. Without a declaration the +# writer would record that DAEMON as this session's identity - a process +# shared by every background session in the home, which outlives any one of +# them. +# Each runs one turn with a one-word prompt, the smallest real session that fires +# a hook. That token cost is deliberate: the alternative is a check that can only +# confirm the assumption already written into its own stub. set -u if [ "${FM_SESSION_LOCK_DECLARATION_DRIFT:-0}" != 1 ]; then @@ -57,9 +65,13 @@ note "claude: ${VERSION:-unknown version} ($CLAUDE_BIN)" # must override it; anything that lets it through is the drift this guard exists # to catch. POISON=2147483646 -mkdir -p "$LAB/.claude" "$LAB/out" -cat > "$LAB/report.sh" < + local shape=$1 + mkdir -p "$LAB/$shape/.claude" "$LAB/$shape/out" + cat > "$LAB/$shape/report.sh" < "$LAB/report.sh" < "$LAB/out/report.txt" 2>&1 +} > "$LAB/$shape/out/report.txt" 2>&1 exit 0 SH -chmod +x "$LAB/report.sh" + chmod +x "$LAB/$shape/report.sh" + printf '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"%s"}]}]}}\n' \ + "$LAB/$shape/report.sh" > "$LAB/$shape/.claude/settings.json" +} -cat > "$LAB/.claude/settings.json" < + sed -n "s/^$2=//p" "$LAB/$1/out/report.txt" | head -1 +} + +# Assert the whole property for one shape: the harness declared a live process of +# its own instead of the planted value, the library reads the same thing, that pid +# is inside the hook's own harness ancestry, and the writer records it. Results go +# to SHAPE_* rather than stdout, so a diagnostic line can never be captured as a +# pid by a caller. +SHAPE_DECLARED= +SHAPE_ANCESTRY= +SHAPE_OUTERMOST= +assert_shape() { # + local shape=$1 what=$2 declared lib_declared writer ancestry outermost + [ -s "$LAB/$shape/out/report.txt" ] \ + || fail "$what: the real session never ran its own SessionStart hook, so nothing was verified: $(tail -3 "$LAB/$shape/out/turn.log" 2>/dev/null)" + declared=$(read_field "$shape" declared) + lib_declared=$(read_field "$shape" lib_declared) + writer=$(read_field "$shape" writer_identity) + ancestry=$(read_field "$shape" ancestry) + + case "$declared" in + ''|none|*[!0-9]*) + fail "$what: claude ${VERSION:-?} no longer declares a numeric session pid to its own hooks (got '$declared'); session-lock identity has silently fallen back to the outermost pid of the ancestry" + ;; + esac + [ "$declared" != "$POISON" ] \ + || fail "$what: claude ${VERSION:-?} passed an INHERITED CLAUDE_PID through to its own hook instead of declaring the session; the writer can no longer tell a session from the plumbing above it" + [ "$lib_declared" = "$declared" ] \ + || fail "$what: the library read '$lib_declared' where the harness declared '$declared'" + [ "$writer" = "$declared" ] \ + || fail "$what: the session-lock writer recorded '$writer' instead of the declared session pid '$declared'; ancestry was: $ancestry" + case " $ancestry " in + *" $declared "*) : ;; + *) fail "$what: the declared session pid '$declared' was not in the hook's own harness ancestry ($ancestry), so the writer's membership check rejected it and fell back" ;; + esac + outermost=$(printf '%s' "$ancestry" | awk '{print $NF}') + SHAPE_DECLARED=$declared + SHAPE_ANCESTRY=$ancestry + SHAPE_OUTERMOST=$outermost + note "$what: declared $declared, ancestry [$ancestry], planted $POISON" +} + +# --- shape 1: a print-mode session, hook directly beneath it ------------------ +setup_lab_shape print +( cd "$LAB/print" && CLAUDE_PID="$POISON" timeout 300 "$CLAUDE_BIN" \ + -p 'Reply with the single word: ok' --dangerously-skip-permissions \ + >"$LAB/print/out/turn.log" 2>&1 ) || true +assert_shape print "print-mode session" + +# --- shape 2: a real background agent under the session-hosting daemon -------- +BG_ID= +stop_background_agent() { + [ -n "${BG_ID:-}" ] || return 0 + "$CLAUDE_BIN" stop "$BG_ID" >/dev/null 2>&1 || true + BG_ID= +} +trap 'stop_background_agent; cleanup_all' EXIT + +setup_lab_shape background +( cd "$LAB/background" && CLAUDE_PID="$POISON" timeout 300 "$CLAUDE_BIN" \ + --bg 'Reply with the single word: ok' --dangerously-skip-permissions \ + >"$LAB/background/out/turn.log" 2>&1 ) || true +BG_ID=$(sed -n 's/^backgrounded[^a-f0-9]*\([0-9a-f][0-9a-f]*\).*/\1/p' "$LAB/background/out/turn.log" | head -1) + +i=0 +while [ "$i" -lt 120 ] && [ ! -s "$LAB/background/out/report.txt" ]; do + sleep 1 + i=$((i + 1)) +done +assert_shape background "background agent" +BG_OUTERMOST=$SHAPE_OUTERMOST +BG_DECLARED=$SHAPE_DECLARED +BG_ANCESTRY=$SHAPE_ANCESTRY + +# The shape only proves anything if the background session really did sit below +# harness-named plumbing. If the chain is one hop, this run did not exercise the +# case the fix exists for and must say so rather than passing vacuously. +[ "$BG_OUTERMOST" != "$BG_DECLARED" ] \ + || fail "background agent: the hook's harness ancestry was a single process ($BG_ANCESTRY), so this run never exercised a daemon-hosted session and proved nothing about it" +note "background agent: without the declaration the writer would have recorded $BG_OUTERMOST, the shared plumbing above this session" -( cd "$LAB" && CLAUDE_PID="$POISON" timeout 300 "$CLAUDE_BIN" \ - -p 'Reply with the single word: ok' --dangerously-skip-permissions >"$LAB/out/turn.log" 2>&1 ) || true - -[ -s "$LAB/out/report.txt" ] \ - || fail "the real session never ran its own SessionStart hook, so nothing was verified: $(tail -3 "$LAB/out/turn.log" 2>/dev/null)" - -read_field() { sed -n "s/^$1=//p" "$LAB/out/report.txt" | head -1; } -DECLARED=$(read_field declared) -LIB_DECLARED=$(read_field lib_declared) -WRITER=$(read_field writer_identity) -ANCESTRY=$(read_field ancestry) - -case "$DECLARED" in - ''|none|*[!0-9]*) - fail "claude ${VERSION:-?} no longer declares a numeric session pid to its own hooks (got '$DECLARED'); the session-lock writer has silently fallen back to the launching session's pid" - ;; -esac -[ "$DECLARED" != "$POISON" ] \ - || fail "claude ${VERSION:-?} passed an INHERITED CLAUDE_PID through to its own hook instead of declaring the session; the writer can no longer tell a background session from a worker of the session above it" -[ "$LIB_DECLARED" = "$DECLARED" ] \ - || fail "the library read '$LIB_DECLARED' where the harness declared '$DECLARED'" -[ "$WRITER" = "$DECLARED" ] \ - || fail "claude ${VERSION:-?}: the session-lock writer recorded '$WRITER' instead of the declared session pid '$DECLARED'; ancestry was: $ANCESTRY" -case " $ANCESTRY " in - *" $DECLARED "*) : ;; - *) fail "claude ${VERSION:-?}: the declared session pid '$DECLARED' was not in the hook's own harness ancestry ($ANCESTRY), so the writer's membership check rejected it and fell back" ;; -esac - -note "declared session pid $DECLARED overrode the planted $POISON; ancestry was: $ANCESTRY" -pass "session-lock: claude ${VERSION:-?} declares its own session process to the processes it spawns, and the writer records it" +stop_background_agent +pass "session-lock: claude ${VERSION:-?} declares its own session process in both a print-mode and a daemon-hosted background session, and the writer records it" From e4f0b6f63bc527efa211f553b55cc5919205a37e Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Mon, 24 Aug 2026 13:45:30 +0100 Subject: [PATCH 05/11] docs: record the background-agent session-declaration measurement --- docs/verification/supervision.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index e745a18f3c0..ff7ffc72f9a 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -299,8 +299,9 @@ Resolving identity as the outermost pid of that chain is right for the worker an `bin/fm-session-lock-lib.sh` therefore prefers the harness's own declaration of its session process, accepted only when it names a live harness inside this contiguous run, and falls back to the outermost pid otherwise. `tests/fm-session-lock-ancestry.test.sh` drives that three-level launcher/daemon/session shape with real processes, the real `bin/fm-lock.sh`, the real Stop auto-arm, and the real turn-end guard, alongside the live-owner, dead-owner, away-mode, and inherited-declaration cases. -Measured with Claude Code 2.1.241 on 2026-08-24: a real session launched with a deliberately wrong `CLAUDE_PID=2147483646` in its environment reported its OWN process to its own `SessionStart` hook, and the writer recorded that pid. -The declaration is authoritative rather than inherited, which is the property the preference depends on. +Measured with Claude Code 2.1.241 on 2026-08-24, in both a print-mode session and a real background agent, each launched with a deliberately wrong `CLAUDE_PID=2147483646` in its environment. +Both reported their OWN process to their own `SessionStart` hook, so the declaration is authoritative rather than inherited, which is the property the preference depends on. +The background agent's hook sat two harness-named hops below the shared session-hosting daemon, whose pid the outermost-pid fallback would have recorded instead: a process every background session in the home shares and which outlives any one of them. Run the opt-in guard after every Claude Code upgrade and before trusting this result: ```sh @@ -309,8 +310,10 @@ FM_SESSION_LOCK_DECLARATION_DRIFT=1 bin/fm-test-run.sh tests/fm-session-lock-dec ```text # claude: 2.1.241 (Claude Code) (/Users/tom/.local/bin/claude) -# declared session pid 26882 overrode the planted 2147483646; ancestry was: 26882 -ok - session-lock: claude 2.1.241 (Claude Code) declares its own session process to the processes it spawns, and the writer records it +# print-mode session: declared 45547, ancestry [45547 ], planted 2147483646 +# background agent: declared 37066, ancestry [37066 36968 90256 ], planted 2147483646 +# background agent: without the declaration the writer would have recorded 90256, the shared plumbing above this session +ok - session-lock: claude 2.1.241 (Claude Code) declares its own session process in both a print-mode and a daemon-hosted background session, and the writer records it ``` `tests/fm-watch-arm.test.sh` runs real watcher and arm cycles against durable on-disk state to verify that a delivered reason survives until post-handling acknowledgement and stops replaying after acknowledgement, while an unrelated queue append cannot make a watcher cycle that delivered nothing look successful. The same suite ingests a keyed remote-secondmate parent reply through the real adapter, establishes the incremental OPEN DECISIONS cursor, interrupts supervision, and proves re-arm replays every unacknowledged queue row plus the still-open decision through the ordinary drain path. From ca613daadaf3ffab7f3f8e9870fd352cfff54530 Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Wed, 26 Aug 2026 16:13:45 +0100 Subject: [PATCH 06/11] docs: keep a machine-local path out of the recorded guard evidence The live session-declaration guard printed the resolved `claude` executable path alongside its version, so the measurement recorded in docs/verification/supervision.md carried an absolute home directory. The version is the identifying fact for that evidence, so the note now prints only the version and the recorded block matches what a real run emits. The guard still resolves and requires an executable `claude`, so an absent harness continues to fail loudly instead of verifying nothing. --- docs/verification/supervision.md | 2 +- tests/fm-session-lock-declaration-live-e2e.test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index ff7ffc72f9a..e6844217dca 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -309,7 +309,7 @@ FM_SESSION_LOCK_DECLARATION_DRIFT=1 bin/fm-test-run.sh tests/fm-session-lock-dec ``` ```text -# claude: 2.1.241 (Claude Code) (/Users/tom/.local/bin/claude) +# claude: 2.1.241 (Claude Code) # print-mode session: declared 45547, ancestry [45547 ], planted 2147483646 # background agent: declared 37066, ancestry [37066 36968 90256 ], planted 2147483646 # background agent: without the declaration the writer would have recorded 90256, the shared plumbing above this session diff --git a/tests/fm-session-lock-declaration-live-e2e.test.sh b/tests/fm-session-lock-declaration-live-e2e.test.sh index 75b4afb1d7e..c1a9611b35a 100755 --- a/tests/fm-session-lock-declaration-live-e2e.test.sh +++ b/tests/fm-session-lock-declaration-live-e2e.test.sh @@ -59,7 +59,7 @@ LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-session-lock-declaration.XXXXXX") || fail "c trap cleanup_all EXIT VERSION=$("$CLAUDE_BIN" --version 2>/dev/null | head -1) -note "claude: ${VERSION:-unknown version} ($CLAUDE_BIN)" +note "claude: ${VERSION:-unknown version}" # A pid no live process can hold, planted in the launch environment. The harness # must override it; anything that lets it through is the drift this guard exists From fd1ca7858b83470705f12c283782af424b264680 Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Wed, 26 Aug 2026 16:32:27 +0100 Subject: [PATCH 07/11] no-mistakes(review): restore over-selecting family map and cover declaration guards --- bin/fm-test-run.sh | 11 +- tests/fm-session-lock-ancestry.test.sh | 148 ++++++++++++++++++++++--- 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 7c18cdc324f..d5b23cdbce0 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -970,11 +970,16 @@ families_for_changed_path() { ;; bin/fm-session-lock-lib.sh) # Session-lock identity: the writer at session start (session-bootstrap), - # the Stop auto-arm's self-ownership check (watcher-wake-lock), and the - # harness-declared session pid the writer prefers, which only a real - # harness can prove (live-harness-optin). + # the Stop auto-arm's self-ownership check (watcher-wake-lock and its + # dedicated unclassified suite), the secondmate harness's own use of the + # ancestry pid (secondmate), and the harness-declared session pid the + # writer prefers, which only a real harness can prove (live-harness-optin). + # This must stay a superset of what the bin/*) reference scan below would + # select, per this map's over-select contract. printf '%s\n' session-bootstrap printf '%s\n' watcher-wake-lock + printf '%s\n' secondmate + printf '%s\n' unclassified printf '%s\n' live-harness-optin ;; bin/fm-sessionstart-run.sh|.claude/settings.json|.codex/hooks.json|\ diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index d3ff595c88f..a6e6f8263ea 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -36,9 +36,16 @@ NAMED_CLAUDE="$FAKEBIN/claude" # Run one library expression with shadowing ps. kill is stubbed so # liveness questions are decided by the process table alone. +# +# The harness declaration is cleared unless the case sets FM_TEST_DECLARED_PID, +# so no assertion here depends on the ambient CLAUDE_PID of whatever session runs +# this suite - a real one colliding with a fixture pid would otherwise decide the +# outcome silently. lib_eval() { # local fakebin=$1 expr=$2 - PATH="$fakebin:$PATH" bash -c " + local -a declaration=(-u CLAUDE_PID) + [ -z "${FM_TEST_DECLARED_PID+x}" ] || declaration=("CLAUDE_PID=$FM_TEST_DECLARED_PID") + env "${declaration[@]}" PATH="$fakebin:$PATH" bash -c " . \"\$0\" kill() { return 0; } $expr @@ -220,6 +227,89 @@ SH pass "session-lock: a live version-named session holding the lock is not mistaken for a stale owner" } +test_declaration_is_trusted_only_inside_this_live_ancestry() { + local dir fakebin got bogus + dir="$TMP_ROOT/declaration-guards" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +# Nothing runs under 410: real ps reports nothing and fails for a dead pid. +[ "$pid" != 410 ] || exit 1 +case "$pid:$field" in + 300:comm=) printf '%s\n' claude ;; + 300:args=) printf '%s\n' claude ;; + 300:ppid=) printf '%s\n' 310 ;; + 310:comm=) printf '%s\n' claude ;; + 310:args=) printf '%s\n' claude ;; + 310:ppid=) printf '%s\n' 320 ;; + 320:comm=) printf '%s\n' claude ;; + 320:args=) printf '%s\n' claude ;; + 320:ppid=) printf '%s\n' 1 ;; + 400:comm=) printf '%s\n' claude ;; + 400:args=) printf '%s\n' claude ;; + 400:ppid=) printf '%s\n' 1 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash /repo/bin/fm-lock.sh' ;; + *:ppid=) printf '%s\n' 300 ;; +esac +SH + chmod +x "$fakebin/ps" + + # The contiguous run is session 300 -> daemon 310 -> launching session 320, so + # the ancestry fallback always answers 320 while every declaration below names + # something else. Which branch decided the identity is therefore readable off + # the answer, and no assertion here can pass for both branches at once. + got=$(lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "the contiguous harness run was not resolved without a declaration" + [ "$got" = 320 ] \ + || fail "with nothing declared the identity must be the outermost pid 320, got '$got'" + + got=$(FM_TEST_DECLARED_PID=300 lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a session pid declared inside the ancestry left identity unresolved" + [ "$got" = 300 ] \ + || fail "a live declaration inside the ancestry must beat the fallback 320, got '$got'" + + # Alive, harness-named, and not in this ancestry: the shape an inherited value + # from an unrelated session takes. Membership alone must reject it. + lib_eval "$fakebin" 'fm_harness_pid_alive 400' \ + || fail "fixture is wrong: 400 must be a live harness for the membership guard to be what rejects it" + got=$(FM_TEST_DECLARED_PID=400 lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a declaration outside the ancestry left identity unresolved instead of falling back" + [ "$got" != 400 ] \ + || fail "a live harness pid outside this ancestry was recorded as this session's identity" + [ "$got" = 320 ] \ + || fail "a declaration outside the ancestry must fall back to 320, got '$got'" + + # Dead: recording it would name an owner no liveness check can ever confirm. + if lib_eval "$fakebin" 'fm_harness_pid_alive 410'; then + fail "fixture is wrong: 410 must be dead for the liveness guard to be what rejects it" + fi + got=$(FM_TEST_DECLARED_PID=410 lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a dead declaration left identity unresolved instead of falling back" + [ "$got" != 410 ] \ + || fail "a dead declared pid was recorded as this session's live identity" + [ "$got" = 320 ] \ + || fail "a dead declaration must fall back to 320, got '$got'" + + for bogus in '' claude-300 '30 0' -300; do + got=$(FM_TEST_DECLARED_PID="$bogus" lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a non-numeric declaration '$bogus' left identity unresolved instead of falling back" + [ "$got" = 320 ] \ + || fail "a non-numeric declaration '$bogus' must fall back to 320, got '$got'" + done + pass "session-lock: a declared session pid is used only while it is live and inside this ancestry" +} + # --- end-to-end layer: the real Stop auto-arm in real process trees ---------- install_autoarm_scripts() { @@ -449,10 +539,11 @@ SH #!/usr/bin/env bash printf '%s\n' "$$" > "$FM_HOME/state/session-pid" # The harness names the session process to everything it spawns; the fixture -# stands in for that. FM_FIXTURE_STALE_DECLARATION models an inherited value -# from the launching session instead of a per-session one. -if [ "${FM_FIXTURE_STALE_DECLARATION:-0}" = 1 ]; then - export CLAUDE_PID=$(cat "$FM_HOME/state/launcher-pid") +# stands in for that. FM_FIXTURE_DECLARED_PID_FILE overrides it with whatever +# the case planted there, which is how an inherited value from somewhere else +# is modelled. +if [ -n "${FM_FIXTURE_DECLARED_PID_FILE:-}" ]; then + export CLAUDE_PID=$(cat "$FM_FIXTURE_DECLARED_PID_FILE") else export CLAUDE_PID=$$ fi @@ -583,30 +674,55 @@ test_bg_session_stays_inert_while_away_mode_owns_supervision() { pass "session-lock: away mode still owns supervision for a background session" } -test_bg_session_falls_back_when_no_per_session_identity_is_declared() { - local dir launcher recorded +test_bg_session_ignores_a_declaration_outside_its_own_ancestry() { + local dir launcher session recorded outsider dir="$TMP_ROOT/bg-session-stale-declaration" make_bg_session_home "$dir" - run_bg_session_tree "$dir" FM_FIXTURE_STALE_DECLARATION=1 + + # A live harness process the fixture's session does not descend from: the shape + # a genuinely stale inherited declaration takes, such as a tmux server started + # from another session and every pane below it. It outlives the lock write so + # liveness cannot be what rejects it - only ancestry membership can. + "$NAMED_CLAUDE" -c ' +i=0 +while [ "$i" -lt 900 ] && [ ! -e "$0" ]; do + sleep 0.05 + i=$((i + 1)) +done +' "$dir/state/outsider-stop" & + outsider=$! + printf '%s\n' "$outsider" > "$dir/state/declared-pid" + + run_bg_session_tree "$dir" FM_FIXTURE_DECLARED_PID_FILE="$dir/state/declared-pid" assert_distinct_chain "$dir" launcher=$(fixture_pid "$dir" launcher-pid) + session=$(fixture_pid "$dir" session-pid) recorded=$(fixture_pid "$dir" lock-after-start) - - # An inherited declaration naming the launching session is indistinguishable - # from a genuine one, so identity falls back to the contiguous run's outermost - # pid. This case pins that fallback as unchanged prior behavior rather than a - # silent new failure mode: the recorded owner is the launching session, and the - # auto-arm consequently stays inert. + ( . "$LIB" && fm_harness_pid_alive "$outsider" ) \ + || fail "fixture is wrong: the declared pid $outsider was not a live harness across the lock write" + : > "$dir/state/outsider-stop" + wait "$outsider" 2>/dev/null || true + + # The three candidate answers are deliberately three different pids, so the + # recorded owner names which rule decided. Trusting the declaration would + # record the outsider; the guard rejects it and the ancestry fallback answers + # the launching session instead, exactly as before this branch. The auto-arm + # consequently stays inert rather than arming blind. + [ "$outsider" != "$launcher" ] && [ "$outsider" != "$session" ] && [ "$launcher" != "$session" ] \ + || fail "fixture did not diverge: outsider=$outsider launcher=$launcher session=$session" + [ "$recorded" != "$outsider" ] \ + || fail "a live harness outside this session's ancestry was recorded as its identity: $outsider" [ "$recorded" = "$launcher" ] \ || fail "an inherited declaration did not fall back to prior behavior: got '$recorded', expected $launcher" expect_code 0 "$(hook_rc "$dir")" "the documented fallback keeps the hook inert, not arming blind" - pass "session-lock: an inherited session declaration falls back to the prior identity unchanged" + pass "session-lock: a declaration outside this session's ancestry is ignored for the prior identity" } test_version_named_session_is_identified_on_both_platforms test_ordinary_paths_are_never_harness_processes test_harness_beyond_a_gap_never_owns_the_lock test_competing_version_named_session_is_seen_as_live +test_declaration_is_trusted_only_inside_this_live_ancestry test_e2e_version_named_session_claims_the_home test_e2e_daemon_parented_session_claims_the_home test_e2e_daemon_parented_version_named_session_keeps_its_lock @@ -614,4 +730,4 @@ test_bg_session_records_its_own_identity_and_keeps_arming test_bg_session_never_claims_a_home_a_live_session_owns test_bg_session_recovers_a_genuinely_dead_owner test_bg_session_stays_inert_while_away_mode_owns_supervision -test_bg_session_falls_back_when_no_per_session_identity_is_declared +test_bg_session_ignores_a_declaration_outside_its_own_ancestry From e9d3248aa59467e1febbda97e485695bbc21050b Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Wed, 26 Aug 2026 16:49:16 +0100 Subject: [PATCH 08/11] no-mistakes(review): assert the turn-end guard verdict in session-lock fixtures --- tests/fm-session-lock-ancestry.test.sh | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index a6e6f8263ea..045bbb4ece6 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -602,6 +602,34 @@ assert_distinct_chain() { # || fail "fixture did not produce three distinct harness levels: launcher=$launcher daemon=$daemon session=$session" } +# The real turn-end guard runs last in every fixture below, on the same Stop +# event as the auto-arm before it, so its verdict is the second half of the same +# identity decision: it may only stand down where this session was recognized as +# its home's owner and the auto-arm therefore claimed recovery. Asserting it is +# what stops a guard that crashed, blocked blindly, or allowed blindly from +# passing unnoticed underneath the identity assertions. +guard_rc() { # + tr -d '[:space:]' < "$1/state/guard.rc" +} + +assert_guard_stood_down() { # + local dir=$1 why=$2 + expect_code 0 "$(guard_rc "$dir")" "$why" + [ ! -s "$dir/state/guard.out" ] \ + || fail "the guard allowed the turn but still printed a banner: $(cat "$dir/state/guard.out")" +} + +assert_guard_blocked_blind_turn() { # + local dir=$1 why=$2 + expect_code 2 "$(guard_rc "$dir")" "$why" + grep -q 'TURN WOULD END BLIND' "$dir/state/guard.out" \ + || fail "the guard blocked without its repair banner: $(cat "$dir/state/guard.out")" + grep -q 'task(s) in flight, but no live watcher holds this home lock' "$dir/state/guard.out" \ + || fail "the guard's banner did not record the supervision need it blocked on: $(cat "$dir/state/guard.out")" + grep -q 'The Stop-owned auto-arm did not claim this home' "$dir/state/guard.out" \ + || fail "the guard's banner did not record that no auto-arm claim covered it: $(cat "$dir/state/guard.out")" +} + test_bg_session_records_its_own_identity_and_keeps_arming() { local dir launcher session recorded dir="$TMP_ROOT/bg-session-sole" @@ -622,6 +650,8 @@ test_bg_session_records_its_own_identity_and_keeps_arming() { || fail "supervision never armed for a background session that owns its home" [ "$(epoch_outcome "$dir")" = rewake ] \ || fail "no claim was recorded for a background session, got: $(epoch_outcome "$dir")" + assert_guard_stood_down "$dir" \ + "the turn-end guard must stand down for the claim the auto-arm just recorded" pass "session-lock: a background session records its own identity and keeps claiming its home" } @@ -643,6 +673,8 @@ test_bg_session_never_claims_a_home_a_live_session_owns() { expect_code 0 "$(hook_rc "$dir")" "a session that does not own the home must stay inert" [ ! -e "$dir/state/arm-ran" ] || fail "a session that does not own the home armed supervision" [ -z "$(epoch_outcome "$dir")" ] || fail "a non-owning session wrote an auto-arm claim" + assert_guard_blocked_blind_turn "$dir" \ + "with no auto-arm claim behind it the turn-end guard must block rather than allow a blind turn" pass "session-lock: a background session never claims a home a live launching session owns" } @@ -659,6 +691,8 @@ test_bg_session_recovers_a_genuinely_dead_owner() { [ -e "$dir/state/arm-ran" ] || fail "supervision never armed after reclaiming a dead owner" [ "$recorded" = "$session" ] \ || fail "the reclaimed lock does not name the recovering session: expected $session, got $recorded" + assert_guard_stood_down "$dir" \ + "the turn-end guard must stand down once the reclaiming session's auto-arm has claimed the home" pass "session-lock: a background session still reclaims a genuinely dead owner" } @@ -671,6 +705,8 @@ test_bg_session_stays_inert_while_away_mode_owns_supervision() { expect_code 0 "$(hook_rc "$dir")" "away mode must keep the auto-arm inert" [ ! -e "$dir/state/arm-ran" ] || fail "the auto-arm armed supervision while away mode owned it" [ -z "$(epoch_outcome "$dir")" ] || fail "the auto-arm claimed the home while away mode owned it" + assert_guard_blocked_blind_turn "$dir" \ + "away mode silences the auto-arm, not the turn-end guard, which must still block a blind turn" pass "session-lock: away mode still owns supervision for a background session" } @@ -715,6 +751,8 @@ done [ "$recorded" = "$launcher" ] \ || fail "an inherited declaration did not fall back to prior behavior: got '$recorded', expected $launcher" expect_code 0 "$(hook_rc "$dir")" "the documented fallback keeps the hook inert, not arming blind" + assert_guard_blocked_blind_turn "$dir" \ + "the ignored declaration leaves no auto-arm claim, so the turn-end guard must block the blind turn" pass "session-lock: a declaration outside this session's ancestry is ignored for the prior identity" } From 7223eb894360849ae4850fee235ed929bcb9a152 Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Wed, 26 Aug 2026 17:08:28 +0100 Subject: [PATCH 09/11] no-mistakes(document): refresh session-lock identity prose for the declared pid --- docs/scripts.md | 2 +- docs/sessionstart-nudge.md | 2 +- docs/verification/supervision.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/scripts.md b/docs/scripts.md index 6f5c66d6078..edb9ca0f3ed 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -38,7 +38,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` `@AGENTS.md` pointer, and the canonical self-governance section | | `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and unhealthy supervision | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | -| `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | +| `fm-session-lock-lib.sh` | Shared session-lock harness identity (which pid identifies this session, ancestry walk, and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index f27d7250293..e85dcc6b609 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -58,7 +58,7 @@ The Guard Predicates section of [`turnend-guard.md`](turnend-guard.md#guard-pred The nudge payload starts with U+2063 and the stable `FIRSTMATE_OP: ` label, carries the current `session-start` protocol kind, and retains exactly ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` as its body. The Ahoy skill owns the rule that this marked operational input is never a captain-authored session boundary, including its narrow legacy compatibility cases, and its own step 0 helm check is the fallback that protects a nudge-tier harness whose first command is a skill. -Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. +Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s own identity resolution (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which walks up to sixteen parents and owns which pid in that run is recorded) and of Pi's `lockOwnership()`. If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. Every path in both wrappers exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization. A lock another session holds and a truncated digest therefore surface as digest text, while broken GitHub auth surfaces through the deferred network result inline or as a wake; none becomes a refusal to open the session. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index e6844217dca..a4ce8164b43 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -315,6 +315,7 @@ FM_SESSION_LOCK_DECLARATION_DRIFT=1 bin/fm-test-run.sh tests/fm-session-lock-dec # background agent: without the declaration the writer would have recorded 90256, the shared plumbing above this session ok - session-lock: claude 2.1.241 (Claude Code) declares its own session process in both a print-mode and a daemon-hosted background session, and the writer records it ``` + `tests/fm-watch-arm.test.sh` runs real watcher and arm cycles against durable on-disk state to verify that a delivered reason survives until post-handling acknowledgement and stops replaying after acknowledgement, while an unrelated queue append cannot make a watcher cycle that delivered nothing look successful. The same suite ingests a keyed remote-secondmate parent reply through the real adapter, establishes the incremental OPEN DECISIONS cursor, interrupts supervision, and proves re-arm replays every unacknowledged queue row plus the still-open decision through the ordinary drain path. It also covers decision-only recovery, interrupted handling, handling-window generation reuse, non-fatal moved-generation acknowledgement with sequence-bounded consumption, and a persistent successor remaining live after recovery is acknowledged. From c71005bdc738d5804f2f0d13427eb65acd60cdd4 Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Thu, 27 Aug 2026 09:41:16 +0100 Subject: [PATCH 10/11] test: silence SC1090 on the ancestry fixture guard source The declared-pid guard sources the session-lock library through a variable, which ShellCheck cannot follow. Annotate that one site with the same source=/dev/null directive the rest of the suite uses for non-constant sources, so bin/fm-lint.sh is clean again. --- tests/fm-session-lock-ancestry.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index 045bbb4ece6..b4af17659fa 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -734,6 +734,7 @@ done launcher=$(fixture_pid "$dir" launcher-pid) session=$(fixture_pid "$dir" session-pid) recorded=$(fixture_pid "$dir" lock-after-start) + # shellcheck source=/dev/null ( . "$LIB" && fm_harness_pid_alive "$outsider" ) \ || fail "fixture is wrong: the declared pid $outsider was not a live harness across the lock write" : > "$dir/state/outsider-stop" From 599f62cd35c805ecaa30bbab581f8864562e8f20 Mon Sep 17 00:00:00 2001 From: Tom Glenn Date: Thu, 27 Aug 2026 09:58:56 +0100 Subject: [PATCH 11/11] no-mistakes(document): point declaration guard header at supervision.md --- tests/fm-session-lock-declaration-live-e2e.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-session-lock-declaration-live-e2e.test.sh b/tests/fm-session-lock-declaration-live-e2e.test.sh index c1a9611b35a..78fc234e894 100755 --- a/tests/fm-session-lock-declaration-live-e2e.test.sh +++ b/tests/fm-session-lock-declaration-live-e2e.test.sh @@ -23,7 +23,7 @@ # on-demand. tests/fm-session-lock-ancestry.test.sh pins the same logic portably # in CI with real processes and no harness. Run this guard after every Claude Code # upgrade and before trusting refreshed evidence in -# docs/verification/runtime-backends.md. +# docs/verification/supervision.md. # # Two real sessions are exercised, because they are different shapes and only the # second is the one the fix exists for: