diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..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 @@ -17,9 +19,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" @@ -34,6 +36,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 "$LOCK") || 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 @@ -91,6 +106,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 d77e563f0b4..f416abcebed 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. @@ -152,20 +152,124 @@ fm_harness_pid_alive() { fm_harness_process_matches "$comm" "$args" } -# 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. +# 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 +# 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 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 +# 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 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 + # 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" +} + +# 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 +# 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 +# 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 "$state/.lock") && [ "$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/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 8ae889f30fe..1c9a3a6d2a2 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -289,14 +289,21 @@ 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. +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. +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. +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 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. -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 @@ -306,8 +313,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..4598ab53db0 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 "$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 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" diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index d7ac74f3736..1c59c07559d 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -220,6 +220,297 @@ 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 ;; + 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 ;; + 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=) + if [ "${FM_TEST_CONTIGUOUS_SESSION:-0}" = 1 ]; then + printf '%s\n' 700 + else + printf '%s\n' 320 + fi + ;; +esac +SH + chmod +x "$1/ps" + cat > "$1/stat" <<'SH' +#!/usr/bin/env bash +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" + 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" + 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" +} + +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 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_same_second_lock_declines_widening_and_falls_back_to_ancestry() { + local dir fakebin same_epoch + 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 same-second fixture timestamp" + + printf '700\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 pool-served same-second lock bypassed the ancestry fallback" + fi + + 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() { + 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. + 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 + + # 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 + + # 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="$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" + 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 +# 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 ;; + "$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 ;; + 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" + 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" + + 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 +651,12 @@ 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_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 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