Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 86 additions & 12 deletions bin/fm-session-lock-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ fm_harness_process_matches() { # <comm> <args>
# 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
Expand All @@ -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() { # <args>
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 <<EOF
$pids
EOF
[ -n "$outermost" ] || return 1
printf '%s\n' "$outermost"
[ -n "$candidate" ] || return 1
printf '%s\n' "$candidate"
}

# True if $1 is a live process that looks like a verified harness.
# True if $1 is a live process that looks like a verified harness and is not
# Claude daemon infrastructure. A live daemon worker (claude daemon run,
# --bg-pty-host, --bg-spare) is long-lived and PPID-1, so it must never count as
# a live session holder: a legacy lock naming the daemon then classifies as stale
# and is recoverable through the normal takeover path.
fm_harness_pid_alive() {
local pid=$1 comm args
kill -0 "$pid" 2>/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
Expand Down
6 changes: 4 additions & 2 deletions docs/verification/supervision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading