From 4812db801628040b609dc25a2a8a91ed5efac662 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:03:04 -0700 Subject: [PATCH 1/3] fix(bin): preserve Claude lock ownership after helper recycling (#4894) * fix(bin): let a background Claude session keep owning its session lock Session-lock ownership was decided by process ancestry alone. Under an unattended Claude session the model loop runs in a transient bg-spare bridged to the front-end by a shared daemon; when that bridge is recycled the contiguous claude-named ancestry from a hook to the recorded owner breaks while the owner pid stays alive, so the Stop auto-arm stood down as a foreign live owner, the turn-end guard ended every turn with its read-only diagnostic, and fm-lock.sh refused - a self-sustaining outage until restart. Ownership is now ancestry membership OR a trusted same-session id, never id-first: - fm-session-lock-lib.sh accepts CLAUDE_CODE_SESSION_ID only when CLAUDE_PID is a Claude-shaped member of the current contiguous run, compares it against the id recorded in state/.lock-session, and requires the recorded pid to still be a live harness. No id, no sidecar, an untrusted id, a different id, or a dead recorded pid leaves the ancestry verdict unchanged. Ids are never read from ps argv. - fm-lock.sh accepts a same-session holder at both refusal sites, writes, refreshes, and clears the sidecar only under its claim lock (including the early already-mine exit, skipped only while the deferred startup sweep leases that lock), keeps it byte-identical across a same-session confirmation, records CLAUDE_PID on lock line 1 for a session with a trusted id so a shared daemon or front-end that outlives the session never keeps a dead session's lock alive, never rewrites a live line 1 on a same-session confirmation, and names the recorded id in the live-owner refusal. - The .lock line-1 format is unchanged, so every reader that takes the whole first line as the pid keeps working; the guard's foreign-owner exit is unchanged and inherits the fix through the shared predicate. Tests: the ancestry suite drives the ancestry and id signals apart in a deterministic process table (asserting the divergence) and runs a real orphaned front-end/daemon/pty-host/spare tree through six phases with the real lock, auto-arm, and guard scripts; the foreign-owner repro keeps its negative control and adds a same-id positive control. Disclosure: no live unattended Claude background session ran on the verifying machine. The topology is documented by the real process listings in #3902, #2314, #3398, and #4066; coverage is the structural predicate plus the executable fixtures, not a live pass. Residual: bin/fm-sessionstart-nudge.sh keeps its own private ancestry walk (it only decides whether to print a nudge) and may nudge on a resume in the recycled case. Out of scope, deliberately: no structured lock format, no guard budget changes, no daemon-identity rejection, no fork lineage. * no-mistakes(review): Wait for claim lock; revert failed sidecars * no-mistakes(review): Revalidate ownership after wait; restore sidecars * no-mistakes(review): Roll back sidecar by publication phase * no-mistakes(review): Restore sidecar only if lock line is unchanged * no-mistakes(review): Trust session ids without a spelling allowlist * no-mistakes(review): Disarm sidecar rollback before backup cleanup * no-mistakes(document): Updated session-lock ownership documentation --- AGENTS.md | 1 + bin/fm-claude-stop-autoarm.sh | 6 +- bin/fm-lock.sh | 192 ++++++- bin/fm-session-lock-lib.sh | 154 +++++- bin/fm-session-start.sh | 9 +- bin/fm-startup-network.sh | 6 +- bin/fm-turnend-guard.sh | 12 +- docs/scripts.md | 2 +- docs/sessionstart-nudge.md | 7 +- docs/turnend-guard.md | 6 +- docs/verification/supervision.md | 27 +- docs/watcher-continuity.md | 5 +- tests/fm-session-lock-ancestry.test.sh | 705 +++++++++++++++++++++++- tests/fm-turnend-foreign-owner-repro.py | 66 ++- 14 files changed, 1130 insertions(+), 68 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c4f62af7dbc..030a12f0ff7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,7 @@ state/ runtime records and signals; gitignored .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, spend cap, and structured mandate clauses; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh) afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window .afk durable away/quiet-mode daemon flag on the harnesses that still launch the daemon (never on Pi); present = sub-supervisor may inject escalations, first line `away` (default, set by /afk, cleared on user return) or `quiet` (set by /quiet, cleared only on explicit /quiet off) per the single owner fm_afk_mode() in bin/fm-wake-lib.sh + .lock-session trusted Claude session-lock sidecar; written only by bin/fm-lock.sh; never touch .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch .cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index df1100ba988..bf09b78431a 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -10,7 +10,11 @@ # - Scope: only a genuine primary checkout (plain checkout or validly marked # secondmate home) with AGENTS.md, bin/, and the effective state dir - the # exact fm-turnend-guard.sh scope. Child crew/scout worktrees stay inert. -# - Identity: only when THIS session's harness ancestor holds state/.lock. +# - Identity: only when THIS session holds state/.lock, as +# bin/fm-session-lock-lib.sh decides it: the recorded pid is a harness +# ancestor, or a live lock was recorded under this same trusted Claude +# session id (which is what keeps a background session arming after its +# transient helper chain is recycled). # When an existing numeric owner fails the shared harness-liveness predicate, # the hook delegates guarded recovery to bin/fm-lock.sh and then re-verifies # ownership. A live owner, missing lock, malformed lock, or unresolved diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..94e26db9620 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,8 +1,25 @@ #!/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. +# +# Line 1 of state/.lock is the owning session's anchor pid, resolved by +# fm_session_lock_anchor_pid in bin/fm-session-lock-lib.sh: the harness (agent) +# process 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. For a Claude session that proves a +# trusted session id the anchor is CLAUDE_PID, the model-loop process, so a +# shared transient daemon or a front-end that outlives the session never keeps +# a dead session's lock alive. Line 1 keeps its whole-line pid format because +# every other reader takes the first line as the pid. +# +# The trusted id itself is recorded beside the lock in state/.lock-session, a +# sidecar written only here and only under the claim lock: refreshed on every +# confirmed-own acquisition, including the early already-mine exit that waits +# for the claim lock, removed when the acquiring session proves no trusted id, +# and left byte-identical when it already names that id. A same-session +# confirmation never rewrites line 1 while the recorded pid is alive, because +# bin/fm-startup-network.sh compares that pid across its deferred sweeps; a dead +# recorded pid is reclaimed and rewritten to this session's anchor. +# # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u @@ -12,14 +29,15 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" LOCK="$STATE/.lock" +LOCK_SESSION="$STATE/.lock-session" mkdir -p "$STATE" 2>/dev/null || { echo "error: cannot create session-lock state directory $STATE; operate read-only until resolved" >&2 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 walk, holder liveness, trusted +# session id, anchor pid) 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" @@ -33,7 +51,7 @@ if [ "${1:-}" = "status" ]; then exit 0 fi -me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } +me=$(fm_session_lock_anchor_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } probe=$(mktemp "$STATE/.lock-write.XXXXXX" 2>/dev/null) || { echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 @@ -46,24 +64,135 @@ rm -f "$probe" 2>/dev/null || { . "$SCRIPT_DIR/fm-wake-lib.sh" CLAIM_LOCK="$STATE/.lock.acquire" CLAIM_LOCK_HELD=0 +# PHASE 0: committed/none. 1: sidecar mutated, line 1 not written. 2: line 1 written, not verified. +# KIND 0: no backup. 1: restore $LOCK_SESSION_PREV. 2: sidecar was absent. +LOCK_SESSION_PHASE=0 +LOCK_SESSION_KIND=0 +LOCK_SESSION_PREV="$STATE/.lock-session.prev" +LOCK_LINE_PRE= release_claim_lock() { if [ "$CLAIM_LOCK_HELD" -eq 1 ]; then fm_lock_release "$CLAIM_LOCK" CLAIM_LOCK_HELD=0 fi } -trap release_claim_lock EXIT +restore_uncommitted_lock_session() { + case "$LOCK_SESSION_PHASE" in + 1) + case "$LOCK_SESSION_KIND" in + 1) mv -f "$LOCK_SESSION_PREV" "$LOCK_SESSION" 2>/dev/null || true ;; + 2) rm -f "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || true ;; + esac + ;; + 2) rm -f "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || true ;; + esac + LOCK_SESSION_PHASE=0 + LOCK_SESSION_KIND=0 +} +commit_lock_session() { + LOCK_SESSION_PHASE=0 + LOCK_SESSION_KIND=0 + rm -f "$LOCK_SESSION_PREV" 2>/dev/null || true +} +on_lock_exit() { + restore_uncommitted_lock_session + [ -n "$LOCK_LINE_PRE" ] && rm -f "$LOCK_LINE_PRE" + release_claim_lock +} +trap on_lock_exit EXIT trap 'exit 1' HUP INT TERM +remember_lock_session() { + [ "$LOCK_SESSION_PHASE" -eq 0 ] || return 0 + if [ -e "$LOCK_SESSION" ] || [ -L "$LOCK_SESSION" ]; then + rm -f "$LOCK_SESSION_PREV" 2>/dev/null || true + cp -P "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || return 1 + LOCK_SESSION_KIND=1 + else + LOCK_SESSION_KIND=2 + fi + LOCK_SESSION_PHASE=1 +} + +# Record the trusted session id beside the lock, or remove a sidecar that no +# trusted id backs. Called only while the claim lock is held. A sidecar already +# naming this id is left untouched, so a same-session confirmation keeps it +# byte-identical. +publish_lock_session() { + local trusted recorded tmp + if trusted=$(fm_session_lock_trusted_session_id); then + if recorded=$(fm_session_lock_recorded_session_id "$STATE") && [ "$recorded" = "$trusted" ]; then + return 0 + fi + remember_lock_session || return 1 + tmp=$(mktemp "$STATE/.lock-session.XXXXXX" 2>/dev/null) || return 1 + if ! { printf '%s\n' "$trusted" > "$tmp" && mv -f "$tmp" "$LOCK_SESSION"; } 2>/dev/null; then + rm -f "$tmp" 2>/dev/null + return 1 + fi + return 0 + fi + if [ -e "$LOCK_SESSION" ] || [ -L "$LOCK_SESSION" ]; then + remember_lock_session || return 1 + rm -f "$LOCK_SESSION" 2>/dev/null || return 1 + fi + return 0 +} + +publish_lock_session_or_die() { + publish_lock_session && return 0 + echo "error: cannot record the session identity beside the lock; operate read-only until resolved" >&2 + exit 1 +} + +# This session already holds the lock, recorded as pid $1. Line 1 stays exactly +# as recorded while that pid is alive; only the sidecar is refreshed, under the +# claim lock, so a /clear re-key inside the same process replaces the old id. +# A same-session confirmation waits for the claim lock so the sidecar refresh +# completes. After the wait, the lock is re-read and the sidecar is refreshed +# only when this session still owns it; otherwise the claim lock is released +# and the caller continues with the ordinary live-owner or reclaim path. The +# prior-session-sweep-is-finishing refusal is a takeover rule and does not +# apply here. +confirm_own_lock() { # + local recorded waited=0 + if [ "$CLAIM_LOCK_HELD" -ne 1 ]; then + fm_lock_acquire_wait "$CLAIM_LOCK" + CLAIM_LOCK_HELD=1 + waited=1 + fi + recorded=$(cat "$LOCK" 2>/dev/null || true) + if [ "$recorded" = "$me" ] || fm_session_lock_owned_by_self "$STATE"; then + publish_lock_session_or_die + commit_lock_session + release_claim_lock + echo "lock acquired: harness pid $recorded" + exit 0 + fi + if [ "$waited" -eq 1 ]; then + release_claim_lock + fi + return 1 +} + +refuse_live_owner() { # + local recorded + if recorded=$(fm_session_lock_recorded_session_id "$STATE"); then + echo "error: another live firstmate session holds the lock (pid $1, session $recorded); operate read-only until resolved" >&2 + else + echo "error: another live firstmate session holds the lock (pid $1); operate read-only until resolved" >&2 + fi + exit 1 +} + if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then old=$(cat "$LOCK" 2>/dev/null || true) - if [ "$old" = "$me" ]; then - echo "lock acquired: harness pid $me" - exit 0 + if [ "$old" = "$me" ] || fm_session_lock_owned_by_self "$STATE"; then + confirm_own_lock "$old" + old=$(cat "$LOCK" 2>/dev/null || true) fi if fm_harness_pid_alive "$old"; then - echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 - exit 1 + refuse_live_owner "$old" fi fi @@ -87,11 +216,45 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then exit 1 } if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then - echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 + fm_session_lock_owned_by_self "$STATE" && confirm_own_lock "$old" + old=$(cat "$LOCK" 2>/dev/null || true) + if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then + refuse_live_owner "$old" + fi + fi +fi +# The sidecar goes first: a fresh pid beside a previous session's id would let +# that session's resume own this lock. If the sidecar changes before line 1 is +# written, a failure restores the previous sidecar. If line 1 is written but +# not yet verified, a failure removes the sidecar and leaves the lock +# ancestry-only. After line 1 verifies as this session's anchor, a later +# signal leaves the published pair in place. +publish_lock_session_or_die +if [ -f "$LOCK" ]; then + LOCK_LINE_PRE=$(mktemp "$STATE/.lock.pre.XXXXXX") || { + echo "error: cannot write session lock; operate read-only until resolved" >&2 + exit 1 + } + if ! cp "$LOCK" "$LOCK_LINE_PRE" 2>/dev/null; then + echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 fi fi +LOCK_SESSION_PHASE=2 if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then + lock_unchanged=0 + if [ -n "$LOCK_LINE_PRE" ] && cmp -s "$LOCK_LINE_PRE" "$LOCK"; then + lock_unchanged=1 + elif [ -z "$LOCK_LINE_PRE" ] && [ ! -e "$LOCK" ] && [ ! -L "$LOCK" ]; then + lock_unchanged=1 + fi + if [ "$lock_unchanged" -eq 1 ]; then + if [ "$LOCK_SESSION_KIND" -ne 0 ]; then + LOCK_SESSION_PHASE=1 + else + LOCK_SESSION_PHASE=0 + fi + fi echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 fi @@ -103,5 +266,6 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then echo "error: session lock ownership verification failed; operate read-only until resolved" >&2 exit 1 fi +commit_lock_session release_claim_lock echo "lock acquired: harness pid $me" diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 7dec38a73a0..a2e3a4c0fef 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -2,10 +2,15 @@ # 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. -# 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. +# lock, and does the current process run inside that same session?" decision. +# bin/fm-lock.sh uses it to acquire and inspect state/.lock and its +# state/.lock-session sidecar; 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. Two signals decide ownership, either one sufficient: the recorded pid +# is a member of this process's contiguous harness ancestry, or the trusted +# Claude session id below matches the id recorded beside a live lock. Neither +# signal ever fails open: no id, no sidecar, an untrusted id, or a different +# recorded id leaves the ancestry verdict exactly as it was. # This file is sourced by scripts and has no side effects on source. # Cursor process identity is NOT expressible as a command-name pattern and is @@ -132,19 +137,24 @@ fm_harness_ancestry_pids() { [ "$printed" -eq 1 ] } -# Print the one pid that identifies this session when the session lock is being -# WRITTEN: the outermost pid of the contiguous run. That is the pid that lives as -# long as the session - a Claude worker several levels in is reaped when its hook -# returns, and a lock naming it would look stale moments later while the session -# is still running. Every non-Claude harness reports a single pid, so this is its -# innermost match unchanged. +# Print the outermost pid of this session's contiguous harness run for callers +# that need that ancestry identity. This is not necessarily the pid written to +# the session lock: fm_session_lock_anchor_pid owns that choice and uses a +# trusted Claude session's model-loop pid instead. Every non-Claude harness +# reports a single pid, so this remains its innermost match unchanged. fm_harness_ancestry_pid() { - local pids pid outermost='' + local pids pids=$(fm_harness_ancestry_pids) || return 1 + _fm_harness_outermost_pid "$pids" +} + +# Print the last (outermost) pid of ancestry list $1, or return 1 when empty. +_fm_harness_outermost_pid() { # + local pid outermost='' while IFS= read -r pid; do [ -n "$pid" ] && outermost=$pid done <] + local id=${CLAUDE_CODE_SESSION_ID:-} claude_pid=${CLAUDE_PID:-} pids=${1:-} pid comm args + [ -n "$id" ] || return 1 + case "$id" in *$'\n'*|*$'\r'*) return 1 ;; esac + case "$claude_pid" in ''|*[!0-9]*) return 1 ;; esac + if [ -z "$pids" ]; then + pids=$(fm_harness_ancestry_pids) || return 1 + fi + while IFS= read -r pid; do + [ "$pid" = "$claude_pid" ] || continue + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + args=$(ps -o args= -p "$pid" 2>/dev/null) + fm_harness_process_matches "$comm" "$args" || return 1 + [ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || return 1 + printf '%s\n' "$id" + return 0 + done < + local state=$1 recorded + [ -f "$state/.lock-session" ] && [ ! -L "$state/.lock-session" ] || return 1 + recorded=$(head -n 1 "$state/.lock-session" 2>/dev/null) || return 1 + [ -n "$recorded" ] || return 1 + case "$recorded" in *$'\n'*|*$'\r'*) return 1 ;; esac + printf '%s\n' "$recorded" +} + +# True when the lock in state dir $1 was recorded by this same Claude session: +# the trusted id equals the id recorded beside the lock. No trusted id, no +# sidecar, or a different recorded id is false. +fm_session_lock_same_session() { # [] + local state=$1 trusted recorded + trusted=$(fm_session_lock_trusted_session_id "${2:-}") || return 1 + recorded=$(fm_session_lock_recorded_session_id "$state") || return 1 + [ "$recorded" = "$trusted" ] +} + +# Print the pid bin/fm-lock.sh records on lock line 1 for this session. For a +# Claude session with a trusted id that is CLAUDE_PID, the model-loop process: +# never the shared transient daemon and never a front-end that outlives the +# session, so "recorded pid dead" keeps meaning "session gone" instead of +# wedging a home behind a live daemon whose session died. A replaced background +# helper leaves a dead pid that its own session's next hook reclaims, because +# the sidecar still names that session. Every other session records the +# outermost pid of its contiguous run, exactly as before. +fm_session_lock_anchor_pid() { + local pids + pids=$(fm_harness_ancestry_pids) || return 1 + if fm_session_lock_trusted_session_id "$pids" >/dev/null; then + printf '%s\n' "$CLAUDE_PID" + return 0 + fi + _fm_harness_outermost_pid "$pids" +} + +# True when state dir $1 holds a session lock that this process's session owns: +# the recorded pid is ANY harness ancestor of the current process, or the lock +# was recorded by this same trusted Claude session and its recorded pid is still +# a live harness. Membership is the honest ancestry test, 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. The same-session path +# requires the recorded pid alive so that a dead one is reclaimed through +# bin/fm-lock.sh's ordinary stale-owner path, which refreshes line 1, rather than +# silently owned with a dead anchor. A missing lock, a malformed lock, a lock +# held by a harness outside this ancestry under another (or no) session id, or +# an ancestry that cannot be resolved all fail closed. fm_session_lock_owned_by_self() { local state=$1 lock_pid pids pid lock_pid=$(cat "$state/.lock" 2>/dev/null || true) @@ -179,13 +282,15 @@ fm_session_lock_owned_by_self() { done < [grace-seconds] [home]` from `bin/fm-wake-lib.sh`, the same PID-strict identity-matched lock and fresh-beacon check used by `bin/fm-watch-arm.sh`: a stale beacon blocks even when a watcher pid is live, and a fresh leftover beacon blocks when the lock is missing, dead, or identity-mismatched. The turn-end guard needs that strict check because it fires at the turn boundary, where the auto-arm is bringing a fresh watcher up for the upcoming idle period, and it cooperates with that arm rather than trusting a beacon left by the cycle that just ended. -When an active home instead has a live session lock held by a verified harness outside the current session's contiguous ancestry, the Claude guard emits a read-only ownership diagnostic and allows the turn to end safely. +When an active home instead has a live session lock held by a verified harness that the current session does not own, the Claude guard emits a read-only ownership diagnostic and allows the turn to end safely. +Ownership is the shared `fm_session_lock_owned_by_self` verdict in `bin/fm-session-lock-lib.sh`: the recorded pid is a member of the current session's contiguous harness ancestry, or the trusted Claude session id recorded beside the lock in `state/.lock-session` matches this hook's own environment while the recorded pid is still a live harness. +That second signal keeps a background Claude session owning its own lock after the transient helper chain between its hooks and its recorded owner is recycled; the library's header owns the trust gate (`CLAUDE_PID` must be a Claude-shaped member of the current run) and `bin/fm-lock.sh` owns the sidecar and the line-1 anchor it records for such a session. That Claude session cannot arm or repair the home without stealing the live owner's lock, so blocking it would create an unbounded loop; the lock-owning session remains responsible for restoring supervision. -Malformed, absent, dead, or ancestry-uncertain lock records do not satisfy this Claude-specific exception and retain the ordinary guard behavior. +Malformed, absent, dead, or ancestry-uncertain lock records do not satisfy this Claude-specific exception and retain the ordinary guard behavior, and a missing or mismatched sidecar or an untrusted id adds nothing to the verdict, so a live owner outside the ancestry still takes this exit exactly as before. `bin/fm-guard.sh`, the pull warning, instead uses the model-aware `fm_watcher_supervision_verdict` from the same library, because it fires mid-turn when the auto-arm model runs no watcher at all. Under the Claude Stop auto-arm model a beacon fresh within grace is healthy even with no live watcher process. A stale beacon is still healthy while `fm_autoarm_midturn_healthy` in `bin/fm-wake-lib.sh` proves a Claude rewake explains the mid-turn gap: the rewake is bound to the current recovery generation and live session-lock owner, and no later watcher beacon or exhausted-failure marker supersedes it, because that session's turn-end will re-arm. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index cfddd97c29a..6e5198fa2ab 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -323,9 +323,34 @@ 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 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: a pid of Claude Code's multi-level `bg-spare` hook worker chain, or an inner pid when a harness-named daemon parents the session. +A background Claude session whose transient helper chain is recycled loses that contiguity while its recorded owner stays alive, so the library also accepts a trusted same-session id: `CLAUDE_CODE_SESSION_ID` counts only when `CLAUDE_PID` is a Claude-shaped member of the current run, it must equal the id `bin/fm-lock.sh` recorded in `state/.lock-session`, and the recorded pid must still be a live harness, while every weaker combination (no id, no sidecar, an untrusted id, a different id, a dead recorded pid) leaves the ancestry verdict unchanged. +For such a session `bin/fm-lock.sh` records `CLAUDE_PID` on lock line 1 instead of the outermost chain pid, so a shared daemon or front-end that outlives the session never keeps a dead session's lock alive, and a same-session confirmation never rewrites a live line 1. 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. +The same suite drives the ancestry and session-id signals apart in that table, asserting the divergence itself so no case is vacuous, and runs a real orphaned front-end, daemon, pty-host, and bg-spare tree whose daemon is ended mid-run: the same id keeps arming through the real `bin/fm-lock.sh`, `bin/fm-claude-stop-autoarm.sh`, and `bin/fm-turnend-guard.sh --claude` with lock line 1 and the sidecar untouched, a different id, an untrusted id, and no id each keep the live-owner refusal naming the recorded id, and the dead front-end is reclaimed onto the spare's pid rather than the outermost pty-host. +`tests/fm-turnend-foreign-owner-repro.py` keeps the genuinely foreign live owner as the negative control and adds the same-id positive control. +Both ran on 2026-09-18 on macOS with bash 3.2.57 as the fake harness interpreter: + +```sh +tests/fm-session-lock-ancestry.test.sh +tests/fm-turnend-foreign-owner-arm-fix.test.sh +``` + +Observed output, bounded to the lines the new coverage adds: + +```text +ok - session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does +ok - session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid +ok - session-lock e2e: a background session keeps its lock and its supervision across a recycled helper chain +same-session acquisition rc=0 stdout='lock acquired: harness pid 41994\nlock_rc=0\n' stderr='' +other-session acquisition rc=0 stdout='lock_rc=1\n' stderr='error: another live firstmate session holds the lock (pid 41994, session synthetic-same); operate read-only until resolved\n' +FIXED same-session id owns the lock; a different id is still foreign +COMPLETE +``` + +No live unattended Claude background session ran on the verifying machine: that topology is documented by the real process listings in issues #3902, #2314, #3398, and #4066, and the coverage above is the structural predicate plus those executable fixtures, not a live pass. +[`sessionstart-nudge.md`](../sessionstart-nudge.md#shared-wrapper-and-safety) owns the nudge wrapper's separate ancestry check and its redundant-nudge behavior after helper-chain recycling. `tests/fm-watch-arm.test.sh` runs real watcher and arm cycles against durable on-disk state to verify that a delivered reason survives until post-handling acknowledgement and stops replaying after acknowledgement, while an unrelated queue append cannot make a watcher cycle that delivered nothing look successful. The same suite ingests a keyed remote-secondmate parent reply through the real adapter, establishes the incremental OPEN DECISIONS cursor, interrupts supervision, and proves re-arm replays every unacknowledged queue row plus the still-open decision through the ordinary drain path. It also covers decision-only recovery, interrupted handling, handling-window generation reuse, non-fatal moved-generation acknowledgement with sequence-bounded consumption, and a persistent successor remaining live after recovery is acknowledged. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 009777636c9..9f79edf94cd 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -14,8 +14,9 @@ omp's replacement follows the same generation-owner contract in `.omp/extensions Cursor's `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) owns routine tokenless re-arm for a Cursor primary by parking that awaited hook on `bin/fm-watch-arm.sh` and returning an actionable close as one follow-up; [`turnend-guard.md`](turnend-guard.md#harness-integrations) owns its Pi-host stand-down, loop bounds, and supersession baton. Claude's `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns routine tokenless re-arm. The hook fires on every Stop, and an eligible primary with supervision need admits one home-scoped owner that foregrounds `bin/fm-watch-arm.sh` inside the hook-owned process tree. -A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner, absent lock, or malformed lock keeps the competing hook inert. -[`turnend-guard.md`](turnend-guard.md#guard-predicates) owns the Claude guard's behavior when that live owner is outside the current session's harness ancestry. +A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner the session does not own, an absent lock, or a malformed lock keeps the competing hook inert. +Whether the session owns that lock is the shared `fm_session_lock_owned_by_self` verdict in `bin/fm-session-lock-lib.sh`, which accepts a recorded pid inside the current harness ancestry or a live lock recorded under this same trusted Claude session id, so a background session keeps arming after its transient helper chain is recycled. +[`turnend-guard.md`](turnend-guard.md#guard-predicates) owns the Claude guard's behavior when that live owner is genuinely another session. The stale-owner claim occurs only after the existing AFK and supervision-need gates pass. After each non-actionable arm close, the hook rechecks the identity-matched watcher lock and fresh beacon before retrying a bounded number of times. A cycle-end failure is benign when that live-watcher predicate is true, and the hook suppresses the arm output and continues silently. diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index dbf1e683f77..381acd6ae85 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -35,12 +35,19 @@ NAMED_CLAUDE="$FAKEBIN/claude" # --- unit layer: identity behind a deterministic process table --------------- # Run one library expression with shadowing ps. kill is stubbed so -# liveness questions are decided by the process table alone. +# liveness questions are decided by the process table alone (FM_TEST_KILL_RC=1 +# makes every pid dead). The suite itself may run inside a Claude session whose +# CLAUDE_CODE_SESSION_ID and CLAUDE_PID would leak into the expression, so both +# are scrubbed and only FM_TEST_SESSION_ID and FM_TEST_CLAUDE_PID reach it. lib_eval() { # local fakebin=$1 expr=$2 - PATH="$fakebin:$PATH" bash -c " + local -a session_env=() + [ -z "${FM_TEST_SESSION_ID:-}" ] || session_env+=("CLAUDE_CODE_SESSION_ID=$FM_TEST_SESSION_ID") + [ -z "${FM_TEST_CLAUDE_PID:-}" ] || session_env+=("CLAUDE_PID=$FM_TEST_CLAUDE_PID") + env -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID ${session_env[@]+"${session_env[@]}"} \ + PATH="$fakebin:$PATH" bash -c " . \"\$0\" - kill() { return 0; } + kill() { return \${FM_TEST_KILL_RC:-0}; } $expr " "$LIB" } @@ -266,6 +273,160 @@ SH pass "session-lock: a live version-named session holding the lock is not mistaken for a stale owner" } +# A background Claude session's process table. The hook fires inside +# `claude bg-spare` (710), whose parent is `claude bg-pty-host` (720). With the +# transient daemon gone the pty-host is reparented to launchd, so the contiguous +# claude-named run from the hook ends at 720 and the live front-end 700 that +# holds the lock is no longer an ancestor at all. FM_TEST_DAEMON_PRESENT=1 puts +# the daemon (730) back between 720 and 700: the healthy topology. +write_background_session_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:${FM_TEST_DAEMON_PRESENT:-0}" in + 700:comm=:*) printf '%s\n' claude ;; + 700:args=:*) printf '%s\n' 'claude --resume' ;; + 700:ppid=:*) printf '%s\n' 1 ;; + 730:comm=:*) printf '%s\n' claude ;; + 730:args=:*) printf '%s\n' 'claude daemon run --origin transient' ;; + 730:ppid=:*) printf '%s\n' 700 ;; + 720:comm=:*) printf '%s\n' 'claude bg-pty-host' ;; + 720:args=:*) printf '%s\n' 'claude bg-pty-host /tmp/pty.sock 120 40 -- claude --bg-spare' ;; + 720:ppid=:1) printf '%s\n' 730 ;; + 720:ppid=:*) printf '%s\n' 1 ;; + 710:comm=:*) printf '%s\n' 'claude bg-spare' ;; + 710:args=:*) printf '%s\n' 'claude bg-spare /tmp/claim.sock' ;; + 710:ppid=:*) printf '%s\n' 720 ;; + *:comm=:*) printf '%s\n' bash ;; + *:args=:*) printf '%s\n' 'bash /repo/bin/fm-claude-stop-autoarm.sh' ;; + *:ppid=:*) printf '%s\n' 710 ;; +esac +SH + chmod +x "$1/ps" +} + +owned() { # + lib_eval "$1" "fm_session_lock_owned_by_self '$2'" +} + +foreign_owner() { # -> prints the foreign pid + lib_eval "$1" "fm_session_lock_foreign_owner_live '$2' && printf '%s' \"\$FM_SESSION_LOCK_FOREIGN_OWNER_PID\"" +} + +test_same_session_id_owns_a_recycled_background_chain() { + local dir fakebin state got + dir="$TMP_ROOT/background-session" + fakebin=$(fm_fakebin "$dir") + state="$dir/state" + mkdir -p "$state" + write_background_session_ps "$fakebin" + printf '700\n' > "$state/.lock" + printf 'S1\n' > "$state/.lock-session" + + # The divergence itself, so none of the verdicts below can be vacuous: with + # the daemon gone the front-end is not an ancestor, with it back it is. + if lib_eval "$fakebin" 'fm_harness_ancestry_pids' | grep -qx 700; then + fail "the recycled chain still reached the front-end, so the id cases would prove nothing" + fi + FM_TEST_DAEMON_PRESENT=1 lib_eval "$fakebin" 'fm_harness_ancestry_pids' | grep -qx 700 \ + || fail "the healthy chain did not reach the front-end" + + # 1. The session's own id from its model-loop process: owned, not foreign. + FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state" \ + || fail "the same session's trusted id did not own the lock after the helper chain was recycled" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null; then + fail "the session's own live front-end was reported as a foreign owner despite the matching id" + fi + # 2. A different id: the existing refusal, naming the live owner. + if FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a different session id claimed a live owner's lock" + fi + got=$(FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state") \ + || fail "a different session id did not see the live owner as foreign" + [ "$got" = 700 ] || fail "the foreign owner pid was '$got', expected 700" + # 3. The trust gate: the right id carried by a CLAUDE_PID outside the run. + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=700 owned "$fakebin" "$state"; then + fail "an id whose CLAUDE_PID is outside the current Claude run was trusted" + fi + FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=700 foreign_owner "$fakebin" "$state" >/dev/null \ + || fail "an untrusted id suppressed the foreign-owner verdict" + printf 'S1:x\n' > "$state/.lock-session" + FM_TEST_SESSION_ID='S1:x' FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state" \ + || fail "a trusted id containing a colon did not own the lock" + if FM_TEST_SESSION_ID='S1:x' FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null; then + fail "a matching id containing a colon was reported as a foreign owner" + fi + printf 'S1\r' > "$state/.lock-session" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a recorded id containing a carriage return was treated as a session id" + fi + FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null \ + || fail "a carriage-return sidecar suppressed the foreign-owner verdict" + printf 'S1\n' > "$state/.lock-session" + # 4. No id at all: the legacy ancestry verdict, unchanged. + if owned "$fakebin" "$state"; then + fail "with no session id the recycled chain claimed the lock" + fi + foreign_owner "$fakebin" "$state" >/dev/null \ + || fail "with no session id the live owner was not reported as foreign" + # 5. The healthy chain owns by ancestry whatever the environment says. + FM_TEST_DAEMON_PRESENT=1 FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state" \ + || fail "ancestry membership lost to a different session id" + FM_TEST_DAEMON_PRESENT=1 owned "$fakebin" "$state" \ + || fail "ancestry membership lost with no session id" + if FM_TEST_DAEMON_PRESENT=1 FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null; then + fail "an ancestor was reported as a foreign owner" + fi + # 6. Never fail open: no sidecar, a symlinked sidecar, and a dead recorded pid + # are all ancestry-only, so the dead one is left for the ordinary reclaim. + rm -f "$state/.lock-session" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a lock with no recorded session id was owned through the environment id" + fi + printf 'S1\n' > "$dir/elsewhere" + ln -s "$dir/elsewhere" "$state/.lock-session" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a symlinked sidecar was trusted" + fi + rm -f "$state/.lock-session" + printf 'S1\n' > "$state/.lock-session" + if FM_TEST_KILL_RC=1 FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a same-session lock whose recorded pid is dead was owned instead of left for reclaim" + fi + pass "session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does" +} + +test_anchor_pid_is_the_model_loop_process_only_for_a_trusted_id() { + local dir fakebin got + dir="$TMP_ROOT/background-anchor" + fakebin=$(fm_fakebin "$dir") + write_background_session_ps "$fakebin" + + got=$(FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for a trusted id" + [ "$got" = 710 ] || fail "a trusted id anchored '$got', expected the model-loop process 710" + got=$(lib_eval "$fakebin" 'fm_session_lock_anchor_pid') || fail "no anchor pid was resolved without an id" + [ "$got" = 720 ] || fail "without an id the anchor was '$got', expected the outermost pid 720" + got=$(FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=700 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for an untrusted id" + [ "$got" = 720 ] || fail "an untrusted id anchored '$got', expected the outermost pid 720" + got=$(FM_TEST_DAEMON_PRESENT=1 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for the healthy chain" + [ "$got" = 700 ] || fail "the healthy chain without an id anchored '$got', expected the outermost pid 700" + got=$(FM_TEST_DAEMON_PRESENT=1 FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for the healthy chain with a trusted id" + [ "$got" = 710 ] || fail "the healthy chain with a trusted id anchored '$got', expected 710 rather than the front-end" + pass "session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid" +} + # --- end-to-end layer: the real Stop auto-arm in real process trees ---------- install_autoarm_scripts() { @@ -339,10 +500,12 @@ SH run_fixture_tree() { # [] local dir=$1 session_bin=$2 daemon_bin=${3:-} i if [ -n "$daemon_bin" ]; then - FM_HOME="$dir" FM_SESSION_BIN="$session_bin" FM_FIXTURE_ORPHAN_HERE=0 \ + env -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID \ + FM_HOME="$dir" FM_SESSION_BIN="$session_bin" FM_FIXTURE_ORPHAN_HERE=0 \ bash -c '"$0" "$1" &' "$daemon_bin" "$dir/daemon.sh" else - FM_HOME="$dir" FM_FIXTURE_ORPHAN_HERE=1 \ + env -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID \ + FM_HOME="$dir" FM_FIXTURE_ORPHAN_HERE=1 \ bash -c '"$0" "$1" &' "$session_bin" "$dir/session.sh" fi i=0 @@ -404,11 +567,543 @@ test_e2e_daemon_parented_version_named_session_keeps_its_lock() { pass "session-lock e2e: a version-named session under a harness-named daemon keeps its own lock" } +# --- end-to-end layer: a background session whose helper chain is recycled --- +# +# The topology the four issue reports (#3902, #2314, #3398, #4066) recorded with +# real process listings: a front-end that acquired the lock, a transient daemon +# under it, the pty-host the daemon spawned, and the bg-spare inside the pty-host +# that runs the model loop and therefore fires every hook. Every fixture process +# is the fake claude, so the ancestry walk sees a contiguous claude-named run +# exactly as in production, and the tree is orphaned before use. The daemon is +# then ended while the front-end stays alive - the recycling that breaks the run +# above the pty-host - and the spare fires the real Stop auto-arm, the real +# turn-end guard, and the real lock script once per phase under a chosen hook +# environment, recording every verdict for the assertions below. + +BG_FIXTURE_PIDS=() +reap_background_fixture() { + local pid + for pid in ${BG_FIXTURE_PIDS[@]+"${BG_FIXTURE_PIDS[@]}"}; do + kill -TERM "$pid" 2>/dev/null || true + done +} +trap 'reap_background_fixture; fm_test_cleanup' EXIT + +make_background_session_home() { # + local dir=$1 + mkdir -p "$dir/state" + git init -q "$dir" + git -C "$dir" commit -q --allow-empty -m init + : > "$dir/AGENTS.md" + : > "$dir/state/task.meta" + # The whole bin, because the real turn-end guard composes far more of it than + # the auto-arm alone; only the arm is replaced by the recording stub above. + cp -R "$ROOT/bin" "$dir/bin" + install_autoarm_scripts "$dir" + # Every fixture script ends in an explicit exit so bash can never tail-exec the + # script under test in place of the fake claude, which would collapse the + # chain the assertions depend on. + cat > "$dir/frontend.sh" <<'SH' +#!/usr/bin/env bash +i=0 +while [ "$i" -lt 200 ] && [ "$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' ')" != 1 ]; do + sleep 0.05 + i=$((i + 1)) +done +printf '%s\n' "$$" > "$FM_HOME/state/frontend-pid" +CLAUDE_CODE_SESSION_ID=S1 CLAUDE_PID=$$ "$FM_HOME/bin/fm-lock.sh" > "$FM_HOME/state/frontend-lock.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/frontend-lock.rc" +"$FM_FIXTURE_CLAUDE" "$FM_HOME/daemon.sh" & +disown +while [ ! -e "$FM_HOME/state/stop-frontend" ]; do sleep 0.05; done +exit 0 +SH + cat > "$dir/daemon.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/daemon-pid" +exec -a 'claude bg-pty-host' "$FM_FIXTURE_CLAUDE" "$FM_HOME/ptyhost.sh" & +while :; do sleep 0.1; done +exit 0 +SH + cat > "$dir/ptyhost.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/ptyhost-pid" +exec -a 'claude bg-spare' "$FM_FIXTURE_CLAUDE" "$FM_HOME/spare.sh" & +while [ ! -e "$FM_HOME/state/stop-spare" ]; do sleep 0.1; done +exit 0 +SH + cat > "$dir/spare.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/spare-pid" +n=1 +while [ ! -e "$FM_HOME/state/stop-spare" ]; do + req="$FM_HOME/state/fire-$n" + if [ -f "$req" ]; then + out="$FM_HOME/state/phase-$n" + mkdir -p "$out" + unset CLAUDE_CODE_SESSION_ID CLAUDE_PID + # shellcheck disable=SC1090 + . "$req" + ( . "$FM_HOME/bin/fm-session-lock-lib.sh" && fm_harness_ancestry_pids ) > "$out/ancestry" 2>/dev/null + printf '%s\n' '{"session_id":"fixture","stop_hook_active":true}' \ + | "$FM_HOME/bin/fm-claude-stop-autoarm.sh" > "$out/hook.out" 2>&1 + printf '%s\n' "$?" > "$out/hook.rc" + printf '%s\n' '{"session_id":"fixture","stop_hook_active":true}' \ + | "$FM_HOME/bin/fm-turnend-guard.sh" --claude > "$out/guard.out" 2>&1 + printf '%s\n' "$?" > "$out/guard.rc" + "$FM_HOME/bin/fm-lock.sh" > "$out/lock.out" 2>&1 + printf '%s\n' "$?" > "$out/lock.rc" + cp "$FM_HOME/state/.lock" "$out/lock-after" + [ ! -e "$FM_HOME/state/.lock-session" ] || cp "$FM_HOME/state/.lock-session" "$out/session-after" + : > "$out/done" + n=$((n + 1)) + fi + sleep 0.05 +done +exit 0 +SH + chmod +x "$dir/frontend.sh" "$dir/daemon.sh" "$dir/ptyhost.sh" "$dir/spare.sh" +} + +wait_for_file() { # + local i=0 + while [ "$i" -lt 400 ] && [ ! -s "$1" ]; do + sleep 0.05 + i=$((i + 1)) + done + [ -s "$1" ] || fail "background-session fixture never produced $2" +} + +fire_phase() { # + local dir=$1 n=$2 + printf '%s\n' "$3" > "$dir/state/fire-$n.tmp" + mv "$dir/state/fire-$n.tmp" "$dir/state/fire-$n" + wait_for_file "$dir/state/phase-$n/hook.rc" "phase $n" + local i=0 + while [ "$i" -lt 400 ] && [ ! -e "$dir/state/phase-$n/done" ]; do + sleep 0.05 + i=$((i + 1)) + done + [ -e "$dir/state/phase-$n/done" ] || fail "background-session fixture never finished phase $n" +} + +phase_value() { # + tr -d '[:space:]' < "$1/state/phase-$2/$3" +} + +arm_count() { # + [ -e "$1/state/arm-ran" ] || { printf '0'; return; } + wc -l < "$1/state/arm-ran" | tr -d ' ' +} + +# The recycled chain must still be treated as the owner: arm, no diagnostic, +# lock accepted, line 1 untouched while the recorded pid lives, sidecar bytes +# untouched. +expect_phase_owned() { #