diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index d77e563f0b..ab6a89500e 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -105,7 +105,11 @@ fm_harness_process_matches() { # # worker chain (hook shell -> claude bg-spare -> claude bg-pty-host -> claude -> # claude), with no non-harness process between them. Which pid in that run is the # session cannot be read off the ancestry at all, so the whole contiguous run is -# reported and the callers below decide what they need from it. +# reported and the callers below decide what they need from it. The run may +# include Claude daemon-infrastructure pids (claude daemon run, --bg-pty-host, +# --bg-spare); those are deliberately left in the printed ancestry so membership +# can still walk through them, and only the election and liveness verdicts below +# skip them. fm_harness_ancestry_pids() { local pid=$$ comm args extending=0 printed=0 for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do @@ -125,31 +129,101 @@ fm_harness_ancestry_pids() { [ "$printed" -eq 1 ] } +# True when argument string $1 marks Claude Code daemon infrastructure rather +# than a real interactive session process: the `claude daemon run` worker, its +# bg-pty-host, or its bg-spare. These are long-lived PPID-1 processes, so a lock +# naming one can never go stale after the real session dies; neither the election +# nor the liveness verdict may ever select one. +# +# Only the leading command/flag tokens of argv are read: the `daemon run` +# subcommand immediately after the command preamble, or a +# --bg-pty-host/--bg-spare flag inside the leading run of options before the +# first bare word. The preamble is the executable token, plus the script-path +# token when a bare interpreter (node, python) execs Claude Code - the same +# npm-install shape fm_harness_process_matches identifies as Claude. Free-text +# argument content - a prompt or launch brief that merely mentions these +# phrases - sits at or beyond the first bare word past the preamble, so it can +# never mark a real session as infrastructure. The verdict is additionally +# gated on FM_HARNESS_IS_CLAUDE: callers classify the same process with +# fm_harness_process_matches first, and a non-Claude harness never matches even +# when its own argv starts with one of these tokens. +fm_harness_daemon_infra() { # + local head rest word + [ "${FM_HARNESS_IS_CLAUDE:-0}" -eq 1 ] || return 1 + case "$1" in + *' '*) head=${1%% *} rest=${1#* } ;; + *) return 1 ;; + esac + case "${head##*/}" in + *node*|*python*) + case "$rest" in + *' '*) rest=${rest#* } ;; + *) return 1 ;; + esac + ;; + esac + case "$rest" in + 'daemon run'|'daemon run '*) return 0 ;; + esac + while :; do + word=${rest%% *} + case "$word" in + --) return 1 ;; + --bg-pty-host|--bg-pty-host=*|--bg-spare|--bg-spare=*) return 0 ;; + -*) ;; + *) return 1 ;; + esac + case "$rest" in + *' '*) rest=${rest#* } ;; + *) return 1 ;; + esac + done +} + # Print the one pid that identifies this session when the session lock is being -# WRITTEN: the outermost pid of the contiguous run. That is the pid that lives as -# long as the session - a Claude worker several levels in is reaped when its hook -# returns, and a lock naming it would look stale moments later while the session -# is still running. Every non-Claude harness reports a single pid, so this is its -# innermost match unchanged. +# WRITTEN: the outermost pid of the contiguous run that is not Claude daemon +# infrastructure. 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. +# +# Claude Code's daemon worker chain (hook shell -> claude bg-spare -> claude +# bg-pty-host -> claude daemon run) is long-lived and PPID-1, so a lock naming +# any of those pids can never go stale after the real session dies. The election +# therefore skips every daemon-infrastructure pid and returns the topmost real +# session process below the chain; if the whole contiguous run is daemon +# infrastructure there is no session pid to elect and the election fails rather +# than writing a doomed lock. fm_harness_ancestry_pid() { - local pids pid outermost='' + local pids pid comm args candidate='' pids=$(fm_harness_ancestry_pids) || return 1 while IFS= read -r pid; do - [ -n "$pid" ] && outermost=$pid + [ -n "$pid" ] || continue + comm=$(ps -o comm= -p "$pid" 2>/dev/null) + args=$(ps -o args= -p "$pid" 2>/dev/null) + fm_harness_process_matches "$comm" "$args" || true + fm_harness_daemon_infra "$args" || candidate=$pid done </dev/null || return 1 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" + fm_harness_process_matches "$comm" "$args" || return 1 + fm_harness_daemon_infra "$args" && return 1 + return 0 } # True when state dir $1 holds a session lock whose pid is ANY harness ancestor diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index d0837023d3..297b1d17d9 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -289,9 +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. +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 non-daemon-infrastructure pid of Claude Code's multi-level `bg-spare` hook worker chain, or an inner pid when a harness-named daemon parents the session. +Claude daemon-infrastructure pids (`claude daemon run`, `--bg-pty-host`, `--bg-spare`) are long-lived PPID-1 processes, so the election never writes one as the lock holder and `fm_harness_pid_alive` never counts one as a live holder; a legacy lock naming the daemon therefore classifies as stale and stays recoverable through `bin/fm-lock.sh`, while `bin/fm-session-lock-lib.sh` owns the exact election and liveness rules. +The markers are recognized only in a Claude process's leading argv command/flag tokens, never inside free-text prompt or launch-brief content, so a real session whose prompt merely mentions them is still elected and counted live, and a non-Claude harness never matches. 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 and the daemon-infrastructure election, liveness, and membership verdicts behind a deterministic process table, then 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. diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index d7ac74f373..f6e2b31a13 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" } +test_election_skips_daemon_infrastructure() { + local dir fakebin got + dir="$TMP_ROOT/daemon-election" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 90:comm=) printf '%s\n' claude ;; + 90:args=) printf '%s\n' claude ;; + 90:ppid=) printf '%s\n' 1 ;; + 100:comm=) printf '%s\n' claude ;; + 100:args=) printf '%s\n' 'claude daemon run --port 1234' ;; + 100:ppid=) printf '%s\n' 90 ;; + 110:comm=) printf '%s\n' claude ;; + 110:args=) printf '%s\n' 'claude --bg-pty-host' ;; + 110:ppid=) printf '%s\n' 100 ;; + 120:comm=) printf '%s\n' claude ;; + 120:args=) printf '%s\n' 'claude --bg-spare' ;; + 120:ppid=) printf '%s\n' 110 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash hook.sh' ;; + *:ppid=) printf '%s\n' 120 ;; +esac +SH + chmod +x "$fakebin/ps" + got=$(lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "election failed to find a real session below the daemon chain" + [ "$got" = 90 ] || fail "election picked '$got', expected the topmost real session pid 90" + pass "session-lock: election skips daemon-infrastructure pids and returns the topmost real session" +} + +test_election_fails_when_only_daemon_infrastructure_matches() { + local dir fakebin + dir="$TMP_ROOT/daemon-only" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 100:comm=) printf '%s\n' claude ;; + 100:args=) printf '%s\n' 'claude daemon run' ;; + 100:ppid=) printf '%s\n' 1 ;; + 110:comm=) printf '%s\n' claude ;; + 110:args=) printf '%s\n' 'claude --bg-pty-host' ;; + 110:ppid=) printf '%s\n' 100 ;; + 120:comm=) printf '%s\n' claude ;; + 120:args=) printf '%s\n' 'claude --bg-spare' ;; + 120:ppid=) printf '%s\n' 110 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash hook.sh' ;; + *:ppid=) printf '%s\n' 120 ;; +esac +SH + chmod +x "$fakebin/ps" + if lib_eval "$fakebin" 'fm_harness_ancestry_pid'; then + fail "election elected a pid from a run that is entirely daemon infrastructure" + fi + pass "session-lock: election fails when only daemon infrastructure matches" +} + +test_daemon_pid_is_never_a_live_holder() { + local dir fakebin + dir="$TMP_ROOT/daemon-liveness" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 90:comm=) printf '%s\n' claude ;; + 90:args=) printf '%s\n' claude ;; + 90:ppid=) printf '%s\n' 1 ;; + 100:comm=) printf '%s\n' claude ;; + 100:args=) printf '%s\n' 'claude daemon run' ;; + 100:ppid=) printf '%s\n' 1 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' bash ;; + *:ppid=) printf '%s\n' 90 ;; +esac +SH + chmod +x "$fakebin/ps" + if lib_eval "$fakebin" 'fm_harness_pid_alive 100'; then + fail "a live claude daemon run pid was accepted as a live session holder" + fi + lib_eval "$fakebin" 'fm_harness_pid_alive 90' \ + || fail "a live real session pid was rejected as a live holder" + pass "session-lock: a live daemon pid is never a live session holder" +} + +test_free_text_daemon_phrases_do_not_mark_a_real_session() { + local dir fakebin got + dir="$TMP_ROOT/daemon-free-text" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 80:comm=) printf '%s\n' claude ;; + 80:args=) printf '%s\n' 'claude daemon run' ;; + 80:ppid=) printf '%s\n' 1 ;; + 90:comm=) printf '%s\n' claude ;; + 90:args=) printf '%s\n' 'claude --model opus -cFIRSTMATE_OP: v1 launch-brief: restart claude daemon run and its --bg-pty-host / --bg-spare workers' ;; + 90:ppid=) printf '%s\n' 80 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash hook.sh' ;; + *:ppid=) printf '%s\n' 90 ;; +esac +SH + chmod +x "$fakebin/ps" + # The daemon markers appear only inside the session's prompt free text, so + # pid 90 is a real session: it must win the election and count as live, + # while the daemon above it still must not. + got=$(lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "a real session with daemon phrases in its prompt text lost the election entirely" + [ "$got" = 90 ] || fail "election picked '$got', expected the real session pid 90" + lib_eval "$fakebin" 'fm_harness_pid_alive 90' \ + || fail "a live session with daemon phrases in its prompt text was rejected as a live holder" + if lib_eval "$fakebin" 'fm_harness_pid_alive 80'; then + fail "the daemon above the free-text session was accepted as a live holder" + fi + pass "session-lock: daemon phrases in prompt free text never mark a real session as infrastructure" +} + +test_node_wrapped_daemon_is_infrastructure() { + local dir fakebin got + dir="$TMP_ROOT/daemon-node-wrapped" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 60:comm=) printf '%s\n' node ;; + 60:args=) printf '%s\n' 'node /opt/node_modules/@anthropic-ai/claude-code/cli.js daemon run' ;; + 60:ppid=) printf '%s\n' 1 ;; + 70:comm=) printf '%s\n' node ;; + 70:args=) printf '%s\n' 'node /opt/node_modules/@anthropic-ai/claude-code/cli.js --resume' ;; + 70:ppid=) printf '%s\n' 60 ;; + 75:comm=) printf '%s\n' node ;; + 75:args=) printf '%s\n' 'node /opt/node_modules/@anthropic-ai/claude-code/cli.js -cFIRSTMATE_OP: v1 launch-brief: restart claude daemon run workers' ;; + 75:ppid=) printf '%s\n' 1 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash hook.sh' ;; + *:ppid=) printf '%s\n' 70 ;; +esac +SH + chmod +x "$fakebin/ps" + # An npm-installed Claude Code runs interpreter-wrapped, so the daemon + # markers sit after the interpreter and script-path preamble; free text in a + # wrapped session's argv still never matches. + got=$(lib_eval "$fakebin" 'fm_harness_ancestry_pid') \ + || fail "election found no real session below the node-wrapped daemon" + [ "$got" = 70 ] || fail "election picked '$got', expected the wrapped session pid 70" + if lib_eval "$fakebin" 'fm_harness_pid_alive 60'; then + fail "a live node-wrapped daemon was accepted as a live session holder" + fi + lib_eval "$fakebin" 'fm_harness_pid_alive 70' \ + || fail "a live node-wrapped session was rejected as a live holder" + lib_eval "$fakebin" 'fm_harness_pid_alive 75' \ + || fail "a live node-wrapped session with daemon phrases in its prompt was rejected" + pass "session-lock: an interpreter-wrapped daemon is infrastructure, wrapped sessions stay real" +} + +test_membership_walks_through_daemon_parented_chain() { + local dir fakebin + dir="$TMP_ROOT/daemon-membership" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 90:comm=) printf '%s\n' claude ;; + 90:args=) printf '%s\n' claude ;; + 90:ppid=) printf '%s\n' 1 ;; + 100:comm=) printf '%s\n' claude ;; + 100:args=) printf '%s\n' 'claude daemon run' ;; + 100:ppid=) printf '%s\n' 90 ;; + 110:comm=) printf '%s\n' claude ;; + 110:args=) printf '%s\n' 'claude --bg-pty-host' ;; + 110:ppid=) printf '%s\n' 100 ;; + 120:comm=) printf '%s\n' claude ;; + 120:args=) printf '%s\n' 'claude --bg-spare' ;; + 120:ppid=) printf '%s\n' 110 ;; + *:comm=) printf '%s\n' bash ;; + *:args=) printf '%s\n' 'bash hook.sh' ;; + *:ppid=) printf '%s\n' 120 ;; +esac +SH + chmod +x "$fakebin/ps" + # A hook under the daemon worker chain must still match an inner session pid + # recorded in the lock, walking through the daemon-infrastructure pids. + printf '100\n' > "$dir/state/.lock" + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ + || fail "membership did not walk through the daemon chain to match the lock pid" + printf '90\n' > "$dir/state/.lock" + lib_eval "$fakebin" "fm_session_lock_owned_by_self '$dir/state'" \ + || fail "membership did not match the topmost real session pid" + pass "session-lock: membership still walks through a daemon-parented chain" +} + +test_non_claude_harnesses_are_untouched() { + local dir fakebin got name + for name in codex pi; do + dir="$TMP_ROOT/nonclaude-$name" + fakebin=$(fm_fakebin "$dir") + mkdir -p "$dir/state" + cat > "$fakebin/ps" <