From 79d3fe3be2589b7760449b4abb8711e172606b02 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 03:04:53 -0400 Subject: [PATCH 1/9] fix(bin): let a pool-served session prove it owns its own lock Claude Code serves tool and hook commands from a per-user worker pool (claude daemon run -> bg-pty-host -> bg-spare) that is reparented to init. The ancestry of such a call terminates at pid 1 inside that pool and never reaches the interactive session that acquired the home's lock, so every ownership check read the session's own lock as a competing session's and forced the primary into read-only for the rest of its life. Restarting did not help: the next re-verification failed the same way. Ownership is now also provable from the session pid the harness itself publishes, which survives the gap ancestry cannot cross. The evidence only ever widens acceptance - a lock this session does not already record as its own is still decided by the ancestry walk - so it can never take a lock away from another session or turn an unheld lock into a held one. The published pid is trusted only while it is still a live Claude process, so an inherited or recycled value proves nothing. Both shared entry points route through it: fm_session_lock_owned_by_self for the Stop auto-arm, and fm-lock.sh, which adopts the recorded pid when it is this session's own so its existing checks compare like with like. --- bin/fm-lock.sh | 13 ++ bin/fm-session-lock-lib.sh | 47 ++++++- docs/verification/supervision.md | 2 + tests/fm-session-lock-ancestry.test.sh | 168 +++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..6f197c78177 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -34,6 +34,19 @@ if [ "${1:-}" = "status" ]; then fi me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } +# A call served by a reparented worker pool is rooted at pid 1, so the session +# that acquired this lock is not in the ancestry $me came from and every check +# below would read this session's own lock as a competing session's. When the +# harness names its session itself and the lock already records exactly that pid, +# this IS the owning session: adopt the recorded pid so those checks compare like +# with like. Ownership is only ever recognized here, never transferred - a lock +# this session does not already hold leaves $me as the ancestry resolved it. +if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then + session_pid=$(fm_harness_session_pid) || session_pid='' + if [ -n "$session_pid" ] && [ "$session_pid" = "$(cat "$LOCK" 2>/dev/null || true)" ]; then + me=$session_pid + fi +fi probe=$(mktemp "$STATE/.lock-write.XXXXXX" 2>/dev/null) || { echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index d77e563f0b4..63e7d242c58 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -152,6 +152,41 @@ fm_harness_pid_alive() { fm_harness_process_matches "$comm" "$args" } +# Print the pid of the session the harness itself publishes, or return 1. +# +# Ancestry answers "which harness am I running inside" only while the caller is +# actually a descendant of its session. Claude Code serves tool and hook +# commands from a per-user worker pool (claude daemon run -> bg-pty-host -> +# bg-spare) that is reparented to init, so the ancestry of such a call +# terminates at pid 1 inside the pool and never reaches the interactive session +# that acquired this home's lock. CLAUDE_PID is exported into every one of those +# commands and names that session directly, which is why it survives the gap +# that ancestry cannot cross. Claude Code is the only verified harness that +# publishes one today; every other harness has no such variable and keeps the +# ancestry-only behavior below unchanged. +# +# The pid is trusted only while it is still a live Claude Code process, so a +# value inherited from an exited session whose pid has been recycled onto +# something else - or onto a different harness entirely - proves nothing. +# +# Trust boundary. The variable is inherited by any child, so on its own it says +# "a Claude session named this pid", never "I am that session". That is why +# callers must use it strictly to WIDEN ownership and never to replace the +# ancestry test: the only conclusion drawn from it here is that a lock ALREADY +# recording this exact pid belongs to a live session rather than a competing +# one, which is true however deep the caller sits below that session. It can +# therefore never let a caller take a lock away from another session, and never +# turns an unheld lock into a held one. +fm_harness_session_pid() { + local pid=${CLAUDE_PID:-} + case "$pid" in + ''|*[!0-9]*) return 1 ;; + esac + fm_harness_pid_alive "$pid" || return 1 + [ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || return 1 + printf '%s\n' "$pid" +} + # True when state dir $1 holds a session lock whose pid is ANY harness ancestor # of the current process: this script runs inside the session that owns the # home's fleet lock. Membership is the honest test of that question, because the @@ -160,12 +195,22 @@ fm_harness_pid_alive() { # and an inner pid when a harness-named daemon parents the session. A missing # lock, a malformed lock, a lock held by a harness outside this ancestry, or an # ancestry that cannot be resolved all fail closed. +# +# Membership proves ownership when it holds, but its absence proves nothing: a +# call served by a reparented worker pool has no ancestry path to its own +# session at all, so a session that genuinely holds this lock would be refused +# its own home and forced read-only. The published session pid answers exactly +# that case and is checked first. It only ever widens acceptance - a lock this +# session does not already hold is still decided by the ancestry walk below. fm_session_lock_owned_by_self() { - local state=$1 lock_pid pids pid + local state=$1 lock_pid pids pid session_pid lock_pid=$(cat "$state/.lock" 2>/dev/null || true) case "$lock_pid" in ''|*[!0-9]*) return 1 ;; esac + if session_pid=$(fm_harness_session_pid) && [ "$session_pid" = "$lock_pid" ]; then + return 0 + fi pids=$(fm_harness_ancestry_pids) || return 1 while IFS= read -r pid; do [ "$pid" = "$lock_pid" ] && return 0 diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 8ae889f30fe..02448a071ae 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -290,6 +290,8 @@ That inertness result is scoped to the builds it exercised: it did not establish The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. 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. +Ancestry alone cannot answer it when Claude Code serves the call from a per-user worker pool that is reparented to init, because the chain then terminates at pid 1 and never reaches the interactive session that acquired the lock. +The session pid Claude Code publishes as `CLAUDE_PID` closes exactly that gap, and it only ever widens acceptance: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it is still a live harness. 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. `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. diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index d7ac74f3736..89fb62e9f9e 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -220,6 +220,170 @@ SH pass "session-lock: a live version-named session holding the lock is not mistaken for a stale owner" } +# --- unit layer: identity when the call is served by a worker pool ----------- + +# Claude Code serves tool and hook commands from a per-user worker pool +# (claude daemon run -> bg-pty-host -> bg-spare) that is reparented to init, so +# the interactive session that acquired the lock is not an ancestor of the call +# at all. Pid 700 below is that live session and it appears NOWHERE in the chain +# the walk can reach; 650 is a second, unrelated live session. +write_pool_ps() { # + cat > "$1/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 +case "$pid:$field" in + 700:comm=) printf '%s\n' claude ;; + 700:args=) printf '%s\n' 'claude --model opus' ;; + 700:ppid=) printf '%s\n' 1 ;; + 650:comm=) printf '%s\n' claude ;; + 650:args=) printf '%s\n' 'claude --model opus' ;; + 650:ppid=) printf '%s\n' 1 ;; + 660:comm=) printf '%s\n' codex ;; + 660:args=) printf '%s\n' 'codex' ;; + 660:ppid=) printf '%s\n' 1 ;; + 300:comm=) printf '%s\n' claude ;; + 300:args=) printf '%s\n' 'claude daemon run' ;; + 300:ppid=) printf '%s\n' 1 ;; + 310:comm=) printf '%s\n' claude ;; + 310:args=) printf '%s\n' 'claude bg-pty-host' ;; + 310:ppid=) printf '%s\n' 300 ;; + 320:comm=) printf '%s\n' claude ;; + 320:args=) printf '%s\n' 'claude bg-spare' ;; + 320:ppid=) printf '%s\n' 310 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash /repo/bin/fm-session-start.sh' ;; + *:ppid=) printf '%s\n' 320 ;; +esac +SH + chmod +x "$1/ps" +} + +test_pool_served_session_owns_the_lock_it_holds() { + local dir fakebin + dir="$TMP_ROOT/pool-owned" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + write_pool_ps "$fakebin" + printf '700\n' > "$dir/state/.lock" + + # The reachable ancestry terminates in the pool at pid 1, so membership alone + # cannot see the session and every re-verification would refuse this home. + [ "$(lib_eval "$fakebin" 'fm_harness_ancestry_pid')" = 300 ] \ + || fail "fixture did not reproduce a pool-served call rooted at pid 1" + CLAUDE_PID=700 lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ + || fail "a session served by a reparented worker pool could not prove it owns its own lock" + pass "session-lock: a session served by a worker pool proves it owns the lock it holds" +} + +test_pool_served_session_never_claims_another_session_lock() { + local dir fakebin + dir="$TMP_ROOT/pool-foreign" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + write_pool_ps "$fakebin" + + # 650 is a different live session. Widening ownership to the published session + # pid must not widen it to any other live harness. + printf '650\n' > "$dir/state/.lock" + if CLAUDE_PID=700 lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + fail "a lock held by a different live session was claimed as this session's own" + fi + pass "session-lock: a pool-served session never claims a lock held by another session" +} + +test_published_session_pid_is_trusted_only_while_it_is_a_live_harness() { + local dir fakebin + dir="$TMP_ROOT/pool-stale" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + write_pool_ps "$fakebin" + + # 999 is not a harness in this table: a stale exported value whose pid has + # been recycled onto something else proves nothing and must fail closed. + printf '999\n' > "$dir/state/.lock" + if CLAUDE_PID=999 lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + fail "a published session pid that is not a live harness was trusted" + fi + if CLAUDE_PID=not-a-pid lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + fail "a malformed published session pid was trusted" + fi + + # 660 is a live harness, but not a Claude one, so the pid cannot be the + # session that published this variable - it was recycled or inherited. + printf '660\n' > "$dir/state/.lock" + if CLAUDE_PID=660 lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + fail "a published session pid pointing at a different harness was trusted" + fi + pass "session-lock: a published session pid is trusted only while it is a live Claude session" +} + +# The reported failure is reached through bin/fm-lock.sh, which compares the +# recorded pid against this call's ancestry and refuses when they differ. The +# session pid here is a real live process so the script's own kill -0 liveness +# check runs unstubbed; only ps is shadowed. +test_lock_acquire_is_not_refused_to_the_session_that_holds_it() { + local dir fakebin session_pid out rc + dir="$TMP_ROOT/pool-acquire" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + sleep 120 & + session_pid=$! + 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 +case "$pid:$field" in + "$FM_TEST_SESSION_PID:comm=") printf '%s\n' claude ;; + "$FM_TEST_SESSION_PID:args=") printf '%s\n' 'claude --model opus' ;; + "$FM_TEST_SESSION_PID:ppid=") printf '%s\n' 1 ;; + 300:comm=) printf '%s\n' claude ;; + 300:args=) printf '%s\n' 'claude daemon run' ;; + 300:ppid=) printf '%s\n' 1 ;; + 310:comm=) printf '%s\n' claude ;; + 310:args=) printf '%s\n' 'claude bg-pty-host' ;; + 310:ppid=) printf '%s\n' 300 ;; + 320:comm=) printf '%s\n' claude ;; + 320:args=) printf '%s\n' 'claude bg-spare' ;; + 320:ppid=) printf '%s\n' 310 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash /repo/bin/fm-lock.sh' ;; + *:ppid=) printf '%s\n' 320 ;; +esac +SH + chmod +x "$fakebin/ps" + printf '%s\n' "$session_pid" > "$dir/state/.lock" + + out=$(PATH="$fakebin:$PATH" FM_TEST_SESSION_PID="$session_pid" CLAUDE_PID="$session_pid" \ + FM_STATE_OVERRIDE="$dir/state" "$ROOT/bin/fm-lock.sh" 2>&1) && rc=0 || rc=$? + kill "$session_pid" 2>/dev/null || true + wait "$session_pid" 2>/dev/null || true + + [ "$rc" -eq 0 ] \ + || fail "the session holding the lock was refused its own home (rc=$rc): $out" + case "$out" in + *"another live firstmate session"*) + fail "the session holding the lock was told another session holds it: $out" ;; + esac + [ "$(tr -d '[:space:]' < "$dir/state/.lock")" = "$session_pid" ] \ + || fail "the lock moved off the session that holds it" + pass "session-lock: acquire is not refused to the pool-served session that already holds the lock" +} + # --- end-to-end layer: the real Stop auto-arm in real process trees ---------- install_autoarm_scripts() { @@ -360,6 +524,10 @@ 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_pool_served_session_owns_the_lock_it_holds +test_pool_served_session_never_claims_another_session_lock +test_published_session_pid_is_trusted_only_while_it_is_a_live_harness +test_lock_acquire_is_not_refused_to_the_session_that_holds_it 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 From 0708d6129985531eb59132c6bf0cbb2000c9ebf7 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 03:11:58 -0400 Subject: [PATCH 2/9] no-mistakes(review): Verify Claude session PID propagation in live hooks --- docs/verification/supervision.md | 7 ++++--- tests/fm-claude-stop-autoarm-live-e2e.test.sh | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 02448a071ae..c640cd6a6be 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -298,7 +298,8 @@ Harness identity is read from the executable path and `argv[0]` as well as the c 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. -The Claude product live path ran with Claude Code 2.1.219 on 2026-07-24: +The Claude product live path ran with Claude Code 2.1.241 on 2026-08-23. +Every real Bash `PreToolUse` hook received the same numeric `CLAUDE_PID`, `fm_harness_session_pid` verified it as a live Claude process while the hook ran, and session start recorded that exact pid as the lock owner across the daemon-served tool path. ```sh claude --version @@ -308,8 +309,8 @@ FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh Observed output: ```text -2.1.219 (Claude Code) -ok - Claude 2.1.219 (Claude Code) live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary +2.1.241 (Claude Code) +ok - Claude 2.1.241 (Claude Code) live E2E propagated one verified CLAUDE_PID through every Bash hook, recorded it as the session lock, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary ``` Current entry points: diff --git a/tests/fm-claude-stop-autoarm-live-e2e.test.sh b/tests/fm-claude-stop-autoarm-live-e2e.test.sh index c7e2cab880b..81b12cb5ebb 100755 --- a/tests/fm-claude-stop-autoarm-live-e2e.test.sh +++ b/tests/fm-claude-stop-autoarm-live-e2e.test.sh @@ -6,7 +6,8 @@ # session lock can run fm-session-start.sh first; session start reclaims the # dead owner; at least two tokenless auto-arm and rewake cycles then complete # with zero model-issued arm commands; and the cooperative guard consumes no -# forced continuation while the hook's launch is healthy. +# forced continuation while the hook's launch is healthy; and every real Bash +# hook receives a live Claude session pid that becomes the acquired lock owner. # The project and FM_HOME are isolated; Claude keeps using its existing managed # authentication. No live fleet home, worktree, or session is touched. # shellcheck disable=SC2016 # the model, not this test shell, reads the prompt text @@ -67,6 +68,8 @@ cat > "$PROJECT/bin/tool-logger.sh" <<'SH' #!/usr/bin/env bash P=$(cat 2>/dev/null || true) printf '%s\n' "$P" | jq -r '.tool_input.command // "unknown"' >> "$FM_HOME/state/tool-calls.log" 2>/dev/null +VALIDATED_PID=$(bash -c '. "$1"; fm_harness_session_pid' _ "$CLAUDE_PROJECT_DIR/bin/fm-session-lock-lib.sh" 2>/dev/null || true) +printf '%s\t%s\n' "${CLAUDE_PID:-}" "$VALIDATED_PID" >> "$FM_HOME/state/claude-session-pids.log" exit 0 SH chmod +x "$PROJECT/bin/tool-logger.sh" @@ -127,6 +130,17 @@ grep -q 'stale: fixture-rapid-2' "$TRANSCRIPT" || fail "second rapid rewake reas || fail "fresh Claude session did not run session start first: $(cat "$HOME_DIR/state/tool-calls.log" 2>/dev/null)" [ "$(cat "$HOME_DIR/state/.lock" 2>/dev/null)" != 9999999 ] \ || fail "session start did not reclaim the stale dead-owner lock" +PUBLISHED_PID=$(awk -F '\t' 'NR == 1 { print $1 }' "$HOME_DIR/state/claude-session-pids.log" 2>/dev/null) +case "$PUBLISHED_PID" in + ''|*[!0-9]*) fail "Claude $CLAUDE_VERSION did not export a numeric CLAUDE_PID to its Bash hook" ;; +esac +awk -F '\t' -v pid="$PUBLISHED_PID" ' + NF != 2 || $1 != pid || $2 != pid { inconsistent = 1 } + END { exit inconsistent } +' "$HOME_DIR/state/claude-session-pids.log" \ + || fail "Claude $CLAUDE_VERSION did not export one live Claude CLAUDE_PID consistently to every Bash hook: $(cat "$HOME_DIR/state/claude-session-pids.log")" +[ "$(cat "$HOME_DIR/state/.lock" 2>/dev/null)" = "$PUBLISHED_PID" ] \ + || fail "session start recorded $(cat "$HOME_DIR/state/.lock" 2>/dev/null), not hook-published CLAUDE_PID $PUBLISHED_PID" if [ -f "$HOME_DIR/state/tool-calls.log" ]; then ! grep -q 'fm-watch-arm.sh' "$HOME_DIR/state/tool-calls.log" \ || fail "model issued an arm command despite Stop-owned continuity: $(cat "$HOME_DIR/state/tool-calls.log")" @@ -161,4 +175,4 @@ printf '%s\n' '{"session_id":"live-owner-control"}' \ [ ! -s "$LAB/live-owner.out" ] && [ ! -s "$LAB/live-owner.err" ] || fail "competing Stop hook produced a rewake while another live session owned the home" wait "$LIVE_OWNER_PID" -printf 'ok - Claude %s live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary\n' "$CLAUDE_VERSION" +printf 'ok - Claude %s live E2E propagated one verified CLAUDE_PID through every Bash hook, recorded it as the session lock, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary\n' "$CLAUDE_VERSION" From d5b637aef9594bd888ff1c2d8dbfaf794273496e Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 03:22:07 -0400 Subject: [PATCH 3/9] no-mistakes(document): Document Claude daemon session-lock identity --- bin/fm-lock.sh | 6 +++--- bin/fm-session-lock-lib.sh | 18 +++++++++--------- docs/scripts.md | 2 +- docs/verification/supervision.md | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 6f197c78177..56ba81effb5 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -17,9 +17,9 @@ mkdir -p "$STATE" 2>/dev/null || { exit 1 } -# Harness identity (FM_HARNESS_RE, ancestry walk, holder liveness) is owned by -# the shared session-lock lib so the Claude Stop auto-arm applies the exact -# same identity contract. +# Harness identity (FM_HARNESS_RE, ancestry or published session identity, and +# holder liveness) is owned by the shared session-lock lib so the Claude Stop +# auto-arm applies the exact same identity contract. # shellcheck source=bin/fm-session-lock-lib.sh . "$SCRIPT_DIR/fm-session-lock-lib.sh" diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 63e7d242c58..ab578128c6a 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -2,7 +2,7 @@ # 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. +# lock, and does the current session own that lock?" 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. @@ -187,14 +187,14 @@ fm_harness_session_pid() { printf '%s\n' "$pid" } -# True when state dir $1 holds a session lock whose pid is ANY harness ancestor -# of the current process: this script runs inside the session that owns the -# home's fleet lock. Membership is the honest test of that question, because the -# lock owner sits at an unknown depth in a contiguous Claude run - it is the -# outermost pid when the hook fires inside the session's own nested worker chain, -# and an inner pid when a harness-named daemon parents the session. A missing -# lock, a malformed lock, a lock held by a harness outside this ancestry, or an -# ancestry that cannot be resolved all fail closed. +# True when state dir $1 holds a session lock owned by the current session. +# Ancestry membership is the ordinary test of that question, because the lock +# owner sits at an unknown depth in a contiguous Claude run - it is the outermost +# pid when the hook fires inside the session's own nested worker chain, and an +# inner pid when a harness-named daemon parents the session. A missing lock, a +# malformed lock, a lock held by a harness outside this ancestry, or an ancestry +# that cannot be resolved all fail closed unless the published-session check +# below establishes the worker-pool case. # # Membership proves ownership when it holds, but its absence proves nothing: a # call served by a reparented worker pool has no ancestry path to its own diff --git a/docs/scripts.md b/docs/scripts.md index 3359c32e6c8..abba8c2cdc3 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 (ancestry, Claude's published session pid, 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/verification/supervision.md b/docs/verification/supervision.md index c640cd6a6be..8ee61e48d5a 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -289,11 +289,11 @@ That inertness result is scoped to the builds it exercised: it did not establish The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. -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. -Ancestry alone cannot answer it when Claude Code serves the call from a per-user worker pool that is reparented to init, because the chain then terminates at pid 1 and never reaches the interactive session that acquired the lock. -The session pid Claude Code publishes as `CLAUDE_PID` closes exactly that gap, and it only ever widens acceptance: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it is still a live harness. +Session-lock ownership in `bin/fm-session-lock-lib.sh` is ordinarily decided against a session's whole contiguous harness ancestry, 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. +Ancestry cannot answer it when Claude Code serves the call from a per-user worker pool reparented to init, because the chain terminates at pid 1 without reaching the interactive session that acquired the lock. +The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness. 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. +`tests/fm-session-lock-ancestry.test.sh` pins both platforms' reporting semantics behind a deterministic process table, covers the reparented worker-pool gap and competing-owner boundary, and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees. `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 98b207f9a6b1ef7aad7f9a5d77639bde2ce426d7 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 03:44:54 -0400 Subject: [PATCH 4/9] no-mistakes: apply CI fixes --- bin/fm-lock.sh | 2 +- bin/fm-session-lock-lib.sh | 31 ++++++++++++++---- docs/verification/supervision.md | 3 +- tests/fm-claude-stop-autoarm-live-e2e.test.sh | 2 +- tests/fm-session-lock-ancestry.test.sh | 32 ++++++++++++++++--- 5 files changed, 55 insertions(+), 15 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 56ba81effb5..1a1fd66bae3 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -42,7 +42,7 @@ me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in # with like. Ownership is only ever recognized here, never transferred - a lock # this session does not already hold leaves $me as the ancestry resolved it. if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then - session_pid=$(fm_harness_session_pid) || session_pid='' + session_pid=$(fm_harness_session_pid "$LOCK") || session_pid='' if [ -n "$session_pid" ] && [ "$session_pid" = "$(cat "$LOCK" 2>/dev/null || true)" ]; then me=$session_pid fi diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index ab578128c6a..20bb481eb70 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -152,7 +152,8 @@ fm_harness_pid_alive() { fm_harness_process_matches "$comm" "$args" } -# Print the pid of the session the harness itself publishes, or return 1. +# Print the pid of the session the harness itself publishes for lock path $1, +# or return 1. # # Ancestry answers "which harness am I running inside" only while the caller is # actually a descendant of its session. Claude Code serves tool and hook @@ -165,9 +166,11 @@ fm_harness_pid_alive() { # publishes one today; every other harness has no such variable and keeps the # ancestry-only behavior below unchanged. # -# The pid is trusted only while it is still a live Claude Code process, so a -# value inherited from an exited session whose pid has been recycled onto -# something else - or onto a different harness entirely - proves nothing. +# The pid is trusted only while it is still a live Claude Code process that +# predates the lock. The lock's existing mtime is process-generation evidence: +# if an exited session's pid is recycled, the replacement process necessarily +# starts after the lock the original session published and is rejected even +# when the replacement is another Claude process. # # Trust boundary. The variable is inherited by any child, so on its own it says # "a Claude session named this pid", never "I am that session". That is why @@ -177,13 +180,27 @@ fm_harness_pid_alive() { # one, which is true however deep the caller sits below that session. It can # therefore never let a caller take a lock away from another session, and never # turns an unheld lock into a held one. -fm_harness_session_pid() { - local pid=${CLAUDE_PID:-} +fm_harness_session_pid() { # + local lock=$1 pid=${CLAUDE_PID:-} started started_epoch lock_epoch case "$pid" in ''|*[!0-9]*) return 1 ;; esac + [ -f "$lock" ] && [ ! -L "$lock" ] || return 1 fm_harness_pid_alive "$pid" || return 1 [ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || return 1 + started=$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null) || return 1 + started=$(printf '%s' "$started" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -n "$started" ] || return 1 + started_epoch=$(LC_ALL=C date -d "$started" +%s 2>/dev/null) \ + || started_epoch=$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$started" +%s 2>/dev/null) \ + || return 1 + lock_epoch=$(stat -f %m "$lock" 2>/dev/null) \ + || lock_epoch=$(stat -c %Y "$lock" 2>/dev/null) \ + || return 1 + case "$started_epoch:$lock_epoch" in + *[!0-9:]*|:*|*:) return 1 ;; + esac + [ "$started_epoch" -le "$lock_epoch" ] || return 1 printf '%s\n' "$pid" } @@ -208,7 +225,7 @@ fm_session_lock_owned_by_self() { case "$lock_pid" in ''|*[!0-9]*) return 1 ;; esac - if session_pid=$(fm_harness_session_pid) && [ "$session_pid" = "$lock_pid" ]; then + if session_pid=$(fm_harness_session_pid "$state/.lock") && [ "$session_pid" = "$lock_pid" ]; then return 0 fi pids=$(fm_harness_ancestry_pids) || return 1 diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 8ee61e48d5a..5412db13831 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -291,7 +291,8 @@ The secondmate-home scope and manual-repair wake path were measured with Claude The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. Session-lock ownership in `bin/fm-session-lock-lib.sh` is ordinarily decided against a session's whole contiguous harness ancestry, 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. Ancestry cannot answer it when Claude Code serves the call from a per-user worker pool reparented to init, because the chain terminates at pid 1 without reaching the interactive session that acquired the lock. -The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness. +The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process start predates the lock publication. +That lock-mtime generation check rejects an old inherited value when its numeric pid has since been recycled onto another Claude 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, covers the reparented worker-pool gap and competing-owner boundary, and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees. `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. diff --git a/tests/fm-claude-stop-autoarm-live-e2e.test.sh b/tests/fm-claude-stop-autoarm-live-e2e.test.sh index 81b12cb5ebb..4598ab53db0 100755 --- a/tests/fm-claude-stop-autoarm-live-e2e.test.sh +++ b/tests/fm-claude-stop-autoarm-live-e2e.test.sh @@ -68,7 +68,7 @@ cat > "$PROJECT/bin/tool-logger.sh" <<'SH' #!/usr/bin/env bash P=$(cat 2>/dev/null || true) printf '%s\n' "$P" | jq -r '.tool_input.command // "unknown"' >> "$FM_HOME/state/tool-calls.log" 2>/dev/null -VALIDATED_PID=$(bash -c '. "$1"; fm_harness_session_pid' _ "$CLAUDE_PROJECT_DIR/bin/fm-session-lock-lib.sh" 2>/dev/null || true) +VALIDATED_PID=$(bash -c '. "$1"; fm_harness_session_pid "$2"' _ "$CLAUDE_PROJECT_DIR/bin/fm-session-lock-lib.sh" "$FM_HOME/state/.lock" 2>/dev/null || true) printf '%s\t%s\n' "${CLAUDE_PID:-}" "$VALIDATED_PID" >> "$FM_HOME/state/claude-session-pids.log" exit 0 SH diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index 89fb62e9f9e..262561908ae 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -243,6 +243,7 @@ case "$pid:$field" in 700:comm=) printf '%s\n' claude ;; 700:args=) printf '%s\n' 'claude --model opus' ;; 700:ppid=) printf '%s\n' 1 ;; + 700:lstart=) printf '%s\n' "${FM_TEST_SESSION_START:-Thu Jan 1 00:00:00 1970}" ;; 650:comm=) printf '%s\n' claude ;; 650:args=) printf '%s\n' 'claude --model opus' ;; 650:ppid=) printf '%s\n' 1 ;; @@ -264,6 +265,15 @@ case "$pid:$field" in esac SH chmod +x "$1/ps" + cat > "$1/stat" <<'SH' +#!/usr/bin/env bash +if [ -n "${FM_TEST_LOCK_MTIME:-}" ]; then + printf '%s\n' "$FM_TEST_LOCK_MTIME" +else + exec /usr/bin/stat "$@" +fi +SH + chmod +x "$1/stat" } test_pool_served_session_owns_the_lock_it_holds() { @@ -278,7 +288,8 @@ test_pool_served_session_owns_the_lock_it_holds() { # cannot see the session and every re-verification would refuse this home. [ "$(lib_eval "$fakebin" 'fm_harness_ancestry_pid')" = 300 ] \ || fail "fixture did not reproduce a pool-served call rooted at pid 1" - CLAUDE_PID=700 lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ + FM_TEST_LOCK_MTIME=172800 CLAUDE_PID=700 \ + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ || fail "a session served by a reparented worker pool could not prove it owns its own lock" pass "session-lock: a session served by a worker pool proves it owns the lock it holds" } @@ -293,13 +304,14 @@ test_pool_served_session_never_claims_another_session_lock() { # 650 is a different live session. Widening ownership to the published session # pid must not widen it to any other live harness. printf '650\n' > "$dir/state/.lock" - if CLAUDE_PID=700 lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + if FM_TEST_LOCK_MTIME=172800 CLAUDE_PID=700 \ + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then fail "a lock held by a different live session was claimed as this session's own" fi pass "session-lock: a pool-served session never claims a lock held by another session" } -test_published_session_pid_is_trusted_only_while_it_is_a_live_harness() { +test_published_session_pid_requires_the_original_live_harness_generation() { local dir fakebin dir="$TMP_ROOT/pool-stale" fakebin=$(fm_fakebin "$dir") @@ -322,7 +334,16 @@ test_published_session_pid_is_trusted_only_while_it_is_a_live_harness() { if CLAUDE_PID=660 lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then fail "a published session pid pointing at a different harness was trusted" fi - pass "session-lock: a published session pid is trusted only while it is a live Claude session" + + # A replacement Claude process can reuse the same numeric pid after the + # original session exits. It is still not the process that published this + # lock, and its start time after the lock mtime must make ownership fail. + printf '700\n' > "$dir/state/.lock" + if FM_TEST_LOCK_MTIME=172800 FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + fail "a replacement Claude process that reused the published pid was trusted" + fi + pass "session-lock: a published session pid requires the original live Claude process generation" } # The reported failure is reached through bin/fm-lock.sh, which compares the @@ -351,6 +372,7 @@ case "$pid:$field" in "$FM_TEST_SESSION_PID:comm=") printf '%s\n' claude ;; "$FM_TEST_SESSION_PID:args=") printf '%s\n' 'claude --model opus' ;; "$FM_TEST_SESSION_PID:ppid=") printf '%s\n' 1 ;; + "$FM_TEST_SESSION_PID:lstart=") exec /usr/bin/ps -p "$pid" -o lstart= ;; 300:comm=) printf '%s\n' claude ;; 300:args=) printf '%s\n' 'claude daemon run' ;; 300:ppid=) printf '%s\n' 1 ;; @@ -526,7 +548,7 @@ test_harness_beyond_a_gap_never_owns_the_lock test_competing_version_named_session_is_seen_as_live test_pool_served_session_owns_the_lock_it_holds test_pool_served_session_never_claims_another_session_lock -test_published_session_pid_is_trusted_only_while_it_is_a_live_harness +test_published_session_pid_requires_the_original_live_harness_generation test_lock_acquire_is_not_refused_to_the_session_that_holds_it test_e2e_version_named_session_claims_the_home test_e2e_daemon_parented_session_claims_the_home From 5cb0afa1b3a6c84dbc4bfba31653f39232a8f2fb Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 04:19:19 -0400 Subject: [PATCH 5/9] no-mistakes: apply CI fixes --- .opencode/plugins/fm-primary-watch-arm.js | 11 +++- bin/fm-lock.sh | 1 + bin/fm-session-lock-lib.sh | 45 +++++++++++++++-- docs/verification/supervision.md | 5 +- tests/fm-pi-watch-extension.test.sh | 3 +- tests/fm-session-lock-ancestry.test.sh | 61 +++++++++++++++++++++++ 6 files changed, 116 insertions(+), 10 deletions(-) diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index d4e8850bb21..46d14ff6081 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -137,7 +137,11 @@ function classifyArmClose(stdout, stderr, code, signal) { const healthy = combined.split(/\r?\n/).find((line) => /^watcher: healthy\b/.test(line)); if (healthy) { return { - kind: "failure", + // The arm still failed to own wake delivery and follows the ordinary + // retry path below. Preserve the distinct readiness verdict so a fast + // child close cannot race streamed-output observation and make the + // turn-end guard mistake this external watcher for an owned failure. + kind: "external", message: `watcher: FAILED - OpenCode arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`, }; } @@ -390,7 +394,10 @@ function spawnArm(paths, sessionID, client, predecessorArmPid = "") { resolveClosed(); releaseChild(); const classification = classifyArmClose(stdout, stderr, code, signal); - settleReadiness(classification.kind === "actionable" ? "wake" : "failed"); + let finalReadiness = "failed"; + if (classification.kind === "actionable") finalReadiness = "wake"; + if (classification.kind === "external") finalReadiness = "external"; + settleReadiness(finalReadiness); const predecessor = String(armChild.pid ?? ""); if (classification.kind === "actionable") { if (restorationInFlight) return; diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 1a1fd66bae3..07326ec1bb8 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -104,6 +104,7 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then exit 1 fi fi +fm_session_lock_wait_until_publishable "$me" if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 20bb481eb70..b9658d7893c 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -167,10 +167,10 @@ fm_harness_pid_alive() { # ancestry-only behavior below unchanged. # # The pid is trusted only while it is still a live Claude Code process that -# predates the lock. The lock's existing mtime is process-generation evidence: -# if an exited session's pid is recycled, the replacement process necessarily -# starts after the lock the original session published and is rejected even -# when the replacement is another Claude process. +# strictly predates the lock. The lock's existing mtime is process-generation +# evidence: if an exited session's pid is recycled, the replacement process +# starts at or after the lock the original session published and is rejected +# even when the replacement is another Claude process. # # Trust boundary. The variable is inherited by any child, so on its own it says # "a Claude session named this pid", never "I am that session". That is why @@ -200,10 +200,45 @@ fm_harness_session_pid() { # case "$started_epoch:$lock_epoch" in *[!0-9:]*|:*|*:) return 1 ;; esac - [ "$started_epoch" -le "$lock_epoch" ] || return 1 + [ "$started_epoch" -lt "$lock_epoch" ] || return 1 printf '%s\n' "$pid" } +# Wait until a new lock for harness pid $1 can carry unambiguous whole-second +# generation evidence when the verified Claude signals are available. Claude's +# published session pid is the only identity that uses lock mtime, so every +# other harness and every unverified environment return immediately. ps exposes +# process start only to whole-second precision on both supported platforms; +# publishing during that same second would make a recycled pid indistinguishable +# from the original process. The bounded wait moves the one initial lock +# publication past that boundary so the strict comparison above can reject +# equality without making a normal just-started session read-only. +fm_session_lock_wait_until_publishable() { # + local pid=$1 started started_epoch now i=0 + [ "${CLAUDE_PID:-}" = "$pid" ] || return 0 + fm_harness_pid_alive "$pid" || return 0 + [ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || return 0 + started=$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null) || return 0 + started=$(printf '%s' "$started" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -n "$started" ] || return 0 + started_epoch=$(LC_ALL=C date -d "$started" +%s 2>/dev/null) \ + || started_epoch=$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$started" +%s 2>/dev/null) \ + || return 0 + case "$started_epoch" in + ''|*[!0-9]*) return 0 ;; + esac + while [ "$i" -lt 40 ]; do + now=$(date +%s 2>/dev/null) || return 0 + case "$now" in + ''|*[!0-9]*) return 0 ;; + esac + [ "$now" -gt "$started_epoch" ] && return 0 + sleep 0.05 + i=$((i + 1)) + done + return 0 +} + # True when state dir $1 holds a session lock owned by the current session. # Ancestry membership is the ordinary test of that question, because the lock # owner sits at an unknown depth in a contiguous Claude run - it is the outermost diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 5412db13831..2acf839dec6 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -291,8 +291,9 @@ The secondmate-home scope and manual-repair wake path were measured with Claude The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. Session-lock ownership in `bin/fm-session-lock-lib.sh` is ordinarily decided against a session's whole contiguous harness ancestry, 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. Ancestry cannot answer it when Claude Code serves the call from a per-user worker pool reparented to init, because the chain terminates at pid 1 without reaching the interactive session that acquired the lock. -The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process start predates the lock publication. -That lock-mtime generation check rejects an old inherited value when its numeric pid has since been recycled onto another Claude session. +The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process start strictly predates the lock publication. +Because both supported `ps` implementations expose the process start at whole-second precision, initial Claude lock publication waits for the next whole-second boundary with a bounded retry count. +The strict lock-mtime generation check then rejects an old inherited value when its numeric pid is recycled onto another Claude session, including a replacement that starts during the original lock-publication second. 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, covers the reparented worker-pool gap and competing-owner boundary, and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees. `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. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index fb473ee0343..73046aa96c0 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -2245,7 +2245,8 @@ if (!promptBody.includes("TURN WOULD END BLIND")) { EOF ) status=$? - expect_code 0 "$status" "OpenCode watch plugin must not treat external healthy output as an owned arm" + [ "$status" -eq 0 ] \ + || fail "OpenCode watch plugin treated external healthy output as an owned arm (status $status): $out" [ -z "$out" ] || fail "OpenCode external-healthy test printed output: $out" pass "OpenCode healthy arm output does not suppress the turn-end guard" } diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index 262561908ae..cf61d59220f 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -343,9 +343,69 @@ test_published_session_pid_requires_the_original_live_harness_generation() { lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then fail "a replacement Claude process that reused the published pid was trusted" fi + + # Whole-second process timestamps cannot distinguish two process generations + # inside one second. Equality therefore fails closed; the real acquisition + # path below proves an ordinary just-started session publishes after the + # ambiguous boundary instead of being rejected. + if FM_TEST_LOCK_MTIME=259200 FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + fail "a same-second replacement Claude process was trusted" + fi pass "session-lock: a published session pid requires the original live Claude process generation" } +test_lock_acquire_publishes_after_the_session_start_second() { + local dir fakebin session_pid out rc started_epoch lock_epoch + dir="$TMP_ROOT/pool-publish-boundary" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + sleep 120 & + session_pid=$! + 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 +case "$pid:$field" in + "$FM_TEST_SESSION_PID:comm=") printf '%s\n' claude ;; + "$FM_TEST_SESSION_PID:args=") printf '%s\n' 'claude --model opus' ;; + "$FM_TEST_SESSION_PID:ppid=") printf '%s\n' 1 ;; + "$FM_TEST_SESSION_PID:lstart=") exec /usr/bin/ps -p "$pid" -o lstart= ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash /repo/bin/fm-lock.sh' ;; + *:ppid=) printf '%s\n' "$FM_TEST_SESSION_PID" ;; +esac +SH + chmod +x "$fakebin/ps" + + out=$(PATH="$fakebin:$PATH" FM_TEST_SESSION_PID="$session_pid" CLAUDE_PID="$session_pid" \ + FM_STATE_OVERRIDE="$dir/state" "$ROOT/bin/fm-lock.sh" 2>&1) && rc=0 || rc=$? + started_epoch=$(LC_ALL=C /usr/bin/ps -p "$session_pid" -o lstart= | \ + xargs -I{} date -d "{}" +%s 2>/dev/null) \ + || started_epoch=$(LC_ALL=C /usr/bin/ps -p "$session_pid" -o lstart= | \ + xargs -I{} date -j -f '%a %b %e %T %Y' "{}" +%s 2>/dev/null) \ + || started_epoch='' + lock_epoch=$(stat -f %m "$dir/state/.lock" 2>/dev/null) \ + || lock_epoch=$(stat -c %Y "$dir/state/.lock" 2>/dev/null) \ + || lock_epoch='' + kill "$session_pid" 2>/dev/null || true + wait "$session_pid" 2>/dev/null || true + + [ "$rc" -eq 0 ] || fail "a just-started Claude session could not acquire its lock (rc=$rc): $out" + [ -n "$started_epoch" ] && [ -n "$lock_epoch" ] \ + || fail "could not read the acquired lock's generation timestamps" + [ "$started_epoch" -lt "$lock_epoch" ] \ + || fail "lock publication did not advance beyond the session start second ($started_epoch >= $lock_epoch)" + pass "session-lock: a just-started Claude session publishes after the ambiguous start second" +} + # The reported failure is reached through bin/fm-lock.sh, which compares the # recorded pid against this call's ancestry and refuses when they differ. The # session pid here is a real live process so the script's own kill -0 liveness @@ -549,6 +609,7 @@ test_competing_version_named_session_is_seen_as_live test_pool_served_session_owns_the_lock_it_holds test_pool_served_session_never_claims_another_session_lock test_published_session_pid_requires_the_original_live_harness_generation +test_lock_acquire_publishes_after_the_session_start_second test_lock_acquire_is_not_refused_to_the_session_that_holds_it test_e2e_version_named_session_claims_the_home test_e2e_daemon_parented_session_claims_the_home From eff0ba2cee34eab1ca392775b13249794058a827 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 13:20:50 -0400 Subject: [PATCH 6/9] fix(bin): scope this branch to the session-lock identity fix Restore .opencode/plugins/fm-primary-watch-arm.js and tests/fm-pi-watch-extension.test.sh to their default-branch content. Those hunks changed OpenCode arm-close readiness classification and the matching test assertion. They are unrelated to issue #2314's lock-identity mechanism, so they do not belong in this pull request's review surface. --- .opencode/plugins/fm-primary-watch-arm.js | 11 ++--------- tests/fm-pi-watch-extension.test.sh | 3 +-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 46d14ff6081..d4e8850bb21 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -137,11 +137,7 @@ function classifyArmClose(stdout, stderr, code, signal) { const healthy = combined.split(/\r?\n/).find((line) => /^watcher: healthy\b/.test(line)); if (healthy) { return { - // The arm still failed to own wake delivery and follows the ordinary - // retry path below. Preserve the distinct readiness verdict so a fast - // child close cannot race streamed-output observation and make the - // turn-end guard mistake this external watcher for an owned failure. - kind: "external", + kind: "failure", message: `watcher: FAILED - OpenCode arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`, }; } @@ -394,10 +390,7 @@ function spawnArm(paths, sessionID, client, predecessorArmPid = "") { resolveClosed(); releaseChild(); const classification = classifyArmClose(stdout, stderr, code, signal); - let finalReadiness = "failed"; - if (classification.kind === "actionable") finalReadiness = "wake"; - if (classification.kind === "external") finalReadiness = "external"; - settleReadiness(finalReadiness); + settleReadiness(classification.kind === "actionable" ? "wake" : "failed"); const predecessor = String(armChild.pid ?? ""); if (classification.kind === "actionable") { if (restorationInFlight) return; diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 73046aa96c0..fb473ee0343 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -2245,8 +2245,7 @@ if (!promptBody.includes("TURN WOULD END BLIND")) { EOF ) status=$? - [ "$status" -eq 0 ] \ - || fail "OpenCode watch plugin treated external healthy output as an owned arm (status $status): $out" + expect_code 0 "$status" "OpenCode watch plugin must not treat external healthy output as an owned arm" [ -z "$out" ] || fail "OpenCode external-healthy test printed output: $out" pass "OpenCode healthy arm output does not suppress the turn-end guard" } From 873f9233d69786d9e0b46dfac7f9cecb0f804fe0 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 13:33:29 -0400 Subject: [PATCH 7/9] no-mistakes(review): Preserve legacy same-second lock ownership --- bin/fm-lock.sh | 7 ++- bin/fm-session-lock-lib.sh | 27 +++++++--- docs/verification/supervision.md | 5 +- tests/fm-session-lock-ancestry.test.sh | 74 ++++++++++++++++++++++---- 4 files changed, 94 insertions(+), 19 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 07326ec1bb8..ea21080dd28 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -105,7 +105,12 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then fi fi fm_session_lock_wait_until_publishable "$me" -if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then +publication=$(mktemp "$STATE/.lock-publish.XXXXXX" 2>/dev/null) || { + echo "error: cannot prepare session lock publication; operate read-only until resolved" >&2 + exit 1 +} +if ! { printf '%s\n' "$me" > "$publication" && chmod u+x "$publication" && mv -f "$publication" "$LOCK"; } 2>/dev/null; then + rm -f "$publication" 2>/dev/null || true echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 fi diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index b9658d7893c..4421a30dbf4 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -152,6 +152,17 @@ fm_harness_pid_alive() { fm_harness_process_matches "$comm" "$args" } +fm_session_lock_has_generation_marker() { # + local mode + mode=$(stat -f %Lp "$1" 2>/dev/null) \ + || mode=$(stat -c %a "$1" 2>/dev/null) \ + || return 1 + case "$mode" in + *[1357][0-7][0-7]) return 0 ;; + esac + return 1 +} + # Print the pid of the session the harness itself publishes for lock path $1, # or return 1. # @@ -166,11 +177,12 @@ fm_harness_pid_alive() { # publishes one today; every other harness has no such variable and keeps the # ancestry-only behavior below unchanged. # -# The pid is trusted only while it is still a live Claude Code process that -# strictly predates the lock. The lock's existing mtime is process-generation -# evidence: if an exited session's pid is recycled, the replacement process -# starts at or after the lock the original session published and is rejected -# even when the replacement is another Claude process. +# The pid is trusted only while it is still a live Claude Code process from a +# generation compatible with the lock. Current locks carry an owner-execute +# marker and require the process to strictly predate the lock. Unmarked locks +# published before that marker existed may share the process-start second so a +# session already running across an in-place update keeps its home. The lock's +# mtime remains process-generation evidence for every other ordering. # # Trust boundary. The variable is inherited by any child, so on its own it says # "a Claude session named this pid", never "I am that session". That is why @@ -200,7 +212,10 @@ fm_harness_session_pid() { # case "$started_epoch:$lock_epoch" in *[!0-9:]*|:*|*:) return 1 ;; esac - [ "$started_epoch" -lt "$lock_epoch" ] || return 1 + if [ "$started_epoch" -ge "$lock_epoch" ]; then + [ "$started_epoch" -eq "$lock_epoch" ] || return 1 + fm_session_lock_has_generation_marker "$lock" && return 1 + fi printf '%s\n' "$pid" } diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 2acf839dec6..f4004839012 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -291,9 +291,10 @@ The secondmate-home scope and manual-repair wake path were measured with Claude The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. Session-lock ownership in `bin/fm-session-lock-lib.sh` is ordinarily decided against a session's whole contiguous harness ancestry, 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. Ancestry cannot answer it when Claude Code serves the call from a per-user worker pool reparented to init, because the chain terminates at pid 1 without reaching the interactive session that acquired the lock. -The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process start strictly predates the lock publication. +The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process generation is compatible with the lock. Because both supported `ps` implementations expose the process start at whole-second precision, initial Claude lock publication waits for the next whole-second boundary with a bounded retry count. -The strict lock-mtime generation check then rejects an old inherited value when its numeric pid is recycled onto another Claude session, including a replacement that starts during the original lock-publication second. +Current lock publications carry an owner-execute generation marker and wait beyond the process-start second, so their strict lock-mtime check rejects an old inherited value when its numeric pid is recycled onto another Claude session, including a replacement that starts during the original lock-publication second. +An unmarked lock from the prior publication contract may equal the live session's process-start second, which preserves ownership for a session already running during an in-place update without widening acceptance to a different recorded pid. 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, covers the reparented worker-pool gap and competing-owner boundary, and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees. `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. diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index cf61d59220f..de293374478 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -267,15 +267,24 @@ SH chmod +x "$1/ps" cat > "$1/stat" <<'SH' #!/usr/bin/env bash -if [ -n "${FM_TEST_LOCK_MTIME:-}" ]; then - printf '%s\n' "$FM_TEST_LOCK_MTIME" -else - exec /usr/bin/stat "$@" -fi +case "${1:-}:${2:-}" in + -f:%m|-c:%Y) + if [ -n "${FM_TEST_LOCK_MTIME:-}" ]; then + printf '%s\n' "$FM_TEST_LOCK_MTIME" + exit 0 + fi + ;; +esac +exec /usr/bin/stat "$@" SH chmod +x "$1/stat" } +lstart_epoch() { # + LC_ALL=C date -d "$1" +%s 2>/dev/null \ + || LC_ALL=C date -j -f '%a %b %e %T %Y' "$1" +%s 2>/dev/null +} + test_pool_served_session_owns_the_lock_it_holds() { local dir fakebin dir="$TMP_ROOT/pool-owned" @@ -311,12 +320,36 @@ test_pool_served_session_never_claims_another_session_lock() { pass "session-lock: a pool-served session never claims a lock held by another session" } +test_live_upgrade_accepts_only_its_legacy_same_second_lock() { + local dir fakebin same_epoch + dir="$TMP_ROOT/pool-live-upgrade" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + write_pool_ps "$fakebin" + same_epoch=$(lstart_epoch 'Sun Jan 4 00:00:00 1970') \ + || fail "could not derive the live upgrade fixture's same-second timestamp" + + printf '700\n' > "$dir/state/.lock" + FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ + || fail "a live upgraded session could not prove ownership of its legacy same-second lock" + + printf '650\n' > "$dir/state/.lock" + if FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then + fail "a live upgraded session claimed another session's legacy same-second lock" + fi + pass "session-lock: a live upgrade accepts only its own legacy same-second lock" +} + test_published_session_pid_requires_the_original_live_harness_generation() { - local dir fakebin + local dir fakebin same_epoch dir="$TMP_ROOT/pool-stale" fakebin=$(fm_fakebin "$dir") mkdir -p "$dir/state" write_pool_ps "$fakebin" + same_epoch=$(lstart_epoch 'Sun Jan 4 00:00:00 1970') \ + || fail "could not derive the replacement fixture's same-second timestamp" # 999 is not a harness in this table: a stale exported value whose pid has # been recycled onto something else proves nothing and must fail closed. @@ -348,9 +381,10 @@ test_published_session_pid_requires_the_original_live_harness_generation() { # inside one second. Equality therefore fails closed; the real acquisition # path below proves an ordinary just-started session publishes after the # ambiguous boundary instead of being rejected. - if FM_TEST_LOCK_MTIME=259200 FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ + chmod u+x "$dir/state/.lock" + if FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then - fail "a same-second replacement Claude process was trusted" + fail "a same-second replacement Claude process was trusted for a current-generation lock" fi pass "session-lock: a published session pid requires the original live Claude process generation" } @@ -403,6 +437,8 @@ SH || fail "could not read the acquired lock's generation timestamps" [ "$started_epoch" -lt "$lock_epoch" ] \ || fail "lock publication did not advance beyond the session start second ($started_epoch >= $lock_epoch)" + [ -x "$dir/state/.lock" ] \ + || fail "the acquired lock did not carry current-generation evidence" pass "session-lock: a just-started Claude session publishes after the ambiguous start second" } @@ -411,7 +447,7 @@ SH # session pid here is a real live process so the script's own kill -0 liveness # check runs unstubbed; only ps is shadowed. test_lock_acquire_is_not_refused_to_the_session_that_holds_it() { - local dir fakebin session_pid out rc + local dir fakebin session_pid started_epoch out rc dir="$TMP_ROOT/pool-acquire" fakebin=$(fm_fakebin "$dir") mkdir -p "$dir/state" @@ -448,9 +484,26 @@ case "$pid:$field" in esac SH chmod +x "$fakebin/ps" + cat > "$fakebin/stat" <<'SH' +#!/usr/bin/env bash +case "${1:-}:${2:-}" in + -f:%m|-c:%Y) + printf '%s\n' "$FM_TEST_LOCK_MTIME" + exit 0 + ;; +esac +exec /usr/bin/stat "$@" +SH + chmod +x "$fakebin/stat" printf '%s\n' "$session_pid" > "$dir/state/.lock" + started_epoch=$(LC_ALL=C /usr/bin/ps -p "$session_pid" -o lstart= | \ + xargs -I{} date -d "{}" +%s 2>/dev/null) \ + || started_epoch=$(LC_ALL=C /usr/bin/ps -p "$session_pid" -o lstart= | \ + xargs -I{} date -j -f '%a %b %e %T %Y' "{}" +%s 2>/dev/null) \ + || started_epoch='' + [ -n "$started_epoch" ] || fail "could not read the live upgrade fixture's session start" - out=$(PATH="$fakebin:$PATH" FM_TEST_SESSION_PID="$session_pid" CLAUDE_PID="$session_pid" \ + out=$(PATH="$fakebin:$PATH" FM_TEST_SESSION_PID="$session_pid" FM_TEST_LOCK_MTIME="$started_epoch" CLAUDE_PID="$session_pid" \ FM_STATE_OVERRIDE="$dir/state" "$ROOT/bin/fm-lock.sh" 2>&1) && rc=0 || rc=$? kill "$session_pid" 2>/dev/null || true wait "$session_pid" 2>/dev/null || true @@ -608,6 +661,7 @@ test_harness_beyond_a_gap_never_owns_the_lock test_competing_version_named_session_is_seen_as_live test_pool_served_session_owns_the_lock_it_holds test_pool_served_session_never_claims_another_session_lock +test_live_upgrade_accepts_only_its_legacy_same_second_lock test_published_session_pid_requires_the_original_live_harness_generation test_lock_acquire_publishes_after_the_session_start_second test_lock_acquire_is_not_refused_to_the_session_that_holds_it From 4d2b4ddced1e578c01463a330e09b3c42a2282db Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 13:45:25 -0400 Subject: [PATCH 8/9] no-mistakes(review): Restore strict session-lock generation checks --- bin/fm-lock.sh | 7 +-- bin/fm-session-lock-lib.sh | 34 ++++++-------- docs/verification/supervision.md | 7 +-- tests/fm-session-lock-ancestry.test.sh | 62 +++++++++++--------------- 4 files changed, 45 insertions(+), 65 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index ea21080dd28..07326ec1bb8 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -105,12 +105,7 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then fi fi fm_session_lock_wait_until_publishable "$me" -publication=$(mktemp "$STATE/.lock-publish.XXXXXX" 2>/dev/null) || { - echo "error: cannot prepare session lock publication; operate read-only until resolved" >&2 - exit 1 -} -if ! { printf '%s\n' "$me" > "$publication" && chmod u+x "$publication" && mv -f "$publication" "$LOCK"; } 2>/dev/null; then - rm -f "$publication" 2>/dev/null || true +if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 fi diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 4421a30dbf4..f416abcebed 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -152,17 +152,6 @@ fm_harness_pid_alive() { fm_harness_process_matches "$comm" "$args" } -fm_session_lock_has_generation_marker() { # - local mode - mode=$(stat -f %Lp "$1" 2>/dev/null) \ - || mode=$(stat -c %a "$1" 2>/dev/null) \ - || return 1 - case "$mode" in - *[1357][0-7][0-7]) return 0 ;; - esac - return 1 -} - # Print the pid of the session the harness itself publishes for lock path $1, # or return 1. # @@ -177,12 +166,11 @@ fm_session_lock_has_generation_marker() { # # publishes one today; every other harness has no such variable and keeps the # ancestry-only behavior below unchanged. # -# The pid is trusted only while it is still a live Claude Code process from a -# generation compatible with the lock. Current locks carry an owner-execute -# marker and require the process to strictly predate the lock. Unmarked locks -# published before that marker existed may share the process-start second so a -# session already running across an in-place update keeps its home. The lock's -# mtime remains process-generation evidence for every other ordering. +# The pid is trusted only while it is still a live Claude Code process that +# strictly predates the lock. The lock's existing mtime is process-generation +# evidence: if an exited session's pid is recycled, the replacement process +# starts at or after the lock the original session published and is rejected +# even when the replacement is another Claude process. # # Trust boundary. The variable is inherited by any child, so on its own it says # "a Claude session named this pid", never "I am that session". That is why @@ -212,10 +200,14 @@ fm_harness_session_pid() { # case "$started_epoch:$lock_epoch" in *[!0-9:]*|:*|*:) return 1 ;; esac - if [ "$started_epoch" -ge "$lock_epoch" ]; then - [ "$started_epoch" -eq "$lock_epoch" ] || return 1 - fm_session_lock_has_generation_marker "$lock" && return 1 - fi + # A session already running when this change landed cannot prove ownership if + # its existing lock was published during its process-start second. That is the + # same behavior the session already had, not a regression: the old writer + # recorded no generation evidence that could distinguish the original process + # from a pid recycled within that second, so this path declines to widen rather + # than inventing evidence. The gap lasts at most that session's lifetime and + # self-heals when the next session publishes after the bounded wait below. + [ "$started_epoch" -lt "$lock_epoch" ] || return 1 printf '%s\n' "$pid" } diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index f4004839012..1c9a3a6d2a2 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -291,10 +291,11 @@ The secondmate-home scope and manual-repair wake path were measured with Claude The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. Session-lock ownership in `bin/fm-session-lock-lib.sh` is ordinarily decided against a session's whole contiguous harness ancestry, 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. Ancestry cannot answer it when Claude Code serves the call from a per-user worker pool reparented to init, because the chain terminates at pid 1 without reaching the interactive session that acquired the lock. -The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process generation is compatible with the lock. +The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process start strictly predates the lock publication. Because both supported `ps` implementations expose the process start at whole-second precision, initial Claude lock publication waits for the next whole-second boundary with a bounded retry count. -Current lock publications carry an owner-execute generation marker and wait beyond the process-start second, so their strict lock-mtime check rejects an old inherited value when its numeric pid is recycled onto another Claude session, including a replacement that starts during the original lock-publication second. -An unmarked lock from the prior publication contract may equal the live session's process-start second, which preserves ownership for a session already running during an in-place update without widening acceptance to a different recorded pid. +The strict lock-mtime generation check then rejects an old inherited value when its numeric pid is recycled onto another Claude session, including a replacement that starts during the original lock-publication second. +A session already running when this change lands cannot widen ownership for a pre-existing lock published during its process-start second because that writer recorded no evidence that distinguishes the original process from a pid recycled within the same second. +That unchanged limitation lasts at most the existing session's lifetime and self-heals when the next session publishes after the bounded wait. 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, covers the reparented worker-pool gap and competing-owner boundary, and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees. `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. diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index de293374478..1c59c07559d 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -261,7 +261,13 @@ case "$pid:$field" in 320:ppid=) printf '%s\n' 310 ;; *:comm=) printf '%s\n' bash ;; *:args=) printf '%s\n' 'bash /repo/bin/fm-session-start.sh' ;; - *:ppid=) printf '%s\n' 320 ;; + *:ppid=) + if [ "${FM_TEST_CONTIGUOUS_SESSION:-0}" = 1 ]; then + printf '%s\n' 700 + else + printf '%s\n' 320 + fi + ;; esac SH chmod +x "$1/ps" @@ -320,26 +326,30 @@ test_pool_served_session_never_claims_another_session_lock() { pass "session-lock: a pool-served session never claims a lock held by another session" } -test_live_upgrade_accepts_only_its_legacy_same_second_lock() { +test_same_second_lock_declines_widening_and_falls_back_to_ancestry() { local dir fakebin same_epoch - dir="$TMP_ROOT/pool-live-upgrade" + dir="$TMP_ROOT/pool-same-second" fakebin=$(fm_fakebin "$dir") mkdir -p "$dir/state" write_pool_ps "$fakebin" same_epoch=$(lstart_epoch 'Sun Jan 4 00:00:00 1970') \ - || fail "could not derive the live upgrade fixture's same-second timestamp" + || fail "could not derive the same-second fixture timestamp" printf '700\n' > "$dir/state/.lock" - FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ - lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ - || fail "a live upgraded session could not prove ownership of its legacy same-second lock" - - printf '650\n' > "$dir/state/.lock" + if FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ + lib_eval "$fakebin" "fm_harness_session_pid '$dir/state/.lock'"; then + fail "a same-second lock widened ownership without process-generation evidence" + fi if FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then - fail "a live upgraded session claimed another session's legacy same-second lock" + fail "a pool-served same-second lock bypassed the ancestry fallback" fi - pass "session-lock: a live upgrade accepts only its own legacy same-second lock" + + FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ + FM_TEST_CONTIGUOUS_SESSION=1 \ + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ + || fail "a same-second lock did not fall back to proven ancestry ownership" + pass "session-lock: a same-second lock declines widening and falls back to ancestry" } test_published_session_pid_requires_the_original_live_harness_generation() { @@ -381,10 +391,9 @@ test_published_session_pid_requires_the_original_live_harness_generation() { # inside one second. Equality therefore fails closed; the real acquisition # path below proves an ordinary just-started session publishes after the # ambiguous boundary instead of being rejected. - chmod u+x "$dir/state/.lock" if FM_TEST_LOCK_MTIME="$same_epoch" FM_TEST_SESSION_START='Sun Jan 4 00:00:00 1970' CLAUDE_PID=700 \ lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'"; then - fail "a same-second replacement Claude process was trusted for a current-generation lock" + fail "a same-second replacement Claude process was trusted" fi pass "session-lock: a published session pid requires the original live Claude process generation" } @@ -437,8 +446,6 @@ SH || fail "could not read the acquired lock's generation timestamps" [ "$started_epoch" -lt "$lock_epoch" ] \ || fail "lock publication did not advance beyond the session start second ($started_epoch >= $lock_epoch)" - [ -x "$dir/state/.lock" ] \ - || fail "the acquired lock did not carry current-generation evidence" pass "session-lock: a just-started Claude session publishes after the ambiguous start second" } @@ -447,7 +454,7 @@ SH # session pid here is a real live process so the script's own kill -0 liveness # check runs unstubbed; only ps is shadowed. test_lock_acquire_is_not_refused_to_the_session_that_holds_it() { - local dir fakebin session_pid started_epoch out rc + local dir fakebin session_pid out rc dir="$TMP_ROOT/pool-acquire" fakebin=$(fm_fakebin "$dir") mkdir -p "$dir/state" @@ -484,26 +491,11 @@ case "$pid:$field" in esac SH chmod +x "$fakebin/ps" - cat > "$fakebin/stat" <<'SH' -#!/usr/bin/env bash -case "${1:-}:${2:-}" in - -f:%m|-c:%Y) - printf '%s\n' "$FM_TEST_LOCK_MTIME" - exit 0 - ;; -esac -exec /usr/bin/stat "$@" -SH - chmod +x "$fakebin/stat" + CLAUDE_PID="$session_pid" FM_TEST_SESSION_PID="$session_pid" \ + lib_eval "$fakebin" "fm_session_lock_wait_until_publishable '$session_pid'" printf '%s\n' "$session_pid" > "$dir/state/.lock" - started_epoch=$(LC_ALL=C /usr/bin/ps -p "$session_pid" -o lstart= | \ - xargs -I{} date -d "{}" +%s 2>/dev/null) \ - || started_epoch=$(LC_ALL=C /usr/bin/ps -p "$session_pid" -o lstart= | \ - xargs -I{} date -j -f '%a %b %e %T %Y' "{}" +%s 2>/dev/null) \ - || started_epoch='' - [ -n "$started_epoch" ] || fail "could not read the live upgrade fixture's session start" - out=$(PATH="$fakebin:$PATH" FM_TEST_SESSION_PID="$session_pid" FM_TEST_LOCK_MTIME="$started_epoch" CLAUDE_PID="$session_pid" \ + out=$(PATH="$fakebin:$PATH" FM_TEST_SESSION_PID="$session_pid" CLAUDE_PID="$session_pid" \ FM_STATE_OVERRIDE="$dir/state" "$ROOT/bin/fm-lock.sh" 2>&1) && rc=0 || rc=$? kill "$session_pid" 2>/dev/null || true wait "$session_pid" 2>/dev/null || true @@ -661,7 +653,7 @@ test_harness_beyond_a_gap_never_owns_the_lock test_competing_version_named_session_is_seen_as_live test_pool_served_session_owns_the_lock_it_holds test_pool_served_session_never_claims_another_session_lock -test_live_upgrade_accepts_only_its_legacy_same_second_lock +test_same_second_lock_declines_widening_and_falls_back_to_ancestry test_published_session_pid_requires_the_original_live_harness_generation test_lock_acquire_publishes_after_the_session_start_second test_lock_acquire_is_not_refused_to_the_session_that_holds_it From 38abc452b022b12dd6a3c701a6f389babccbe388 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sun, 23 Aug 2026 13:58:47 -0400 Subject: [PATCH 9/9] no-mistakes(document): Document published Claude session lock identity --- bin/fm-lock.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 07326ec1bb8..6c7a9ecdf97 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,8 +1,10 @@ #!/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 verified harness (agent) process PID that identifies the session. +# This ordinarily comes from the shell's ancestry; a Claude call served through +# a reparented worker pool may instead retain its already-recorded published +# session PID. Either PID lives as long as the firstmate session, unlike the +# transient subshell PID of any one tool call. # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u