diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index d20a7dfaa6..481bcd5889 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -53,6 +53,7 @@ Within the Pi family, only the exact launch-boundary marker `FM_PI_HARNESS=pi-si On `unknown`, ask the captain instead of guessing. A captain override always beats detection. When verifying a new adapter, record its env marker and command name in `bin/fm-harness.sh`. +Also observe, in a real child of a real session of that harness, whether it exports a launch marker naming its own session pid: `FM_SESSION_LAUNCH_MARKERS` in `bin/fm-session-lock-lib.sh` has a verified row for Claude alone, and every other harness decides session-lock ownership by process ancestry until a row is verified for it, which [`docs/verification/runtime-backends.md`](../../../docs/verification/runtime-backends.md#session-lock-identity-and-the-suspended-holder) owns. For stuck recovery, the target window's harness is recorded as `harness=` in `state/.meta`. Use that value for interrupt, exit, resume, and skill-invocation facts. diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 89ce011f6b..e105346989 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -101,18 +101,19 @@ fm_hook_payload_is_foreign_host "$PAYLOAD" && exit 0 fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 # --- identity: only the lock-owning session's hooks may arm ------------------ -# A prior session may have died after leaving its numeric harness pid in .lock. -# Use the shared liveness predicate to recognize only that stale-owner case. +# A prior session may have left its numeric harness pid in .lock after dying or +# being suspended. The shared holder predicate recognizes exactly those +# recoverable cases and keeps a genuinely competing session inert. # Defer the mutating claim until after the unchanged AFK and need gates, so an # idle or away home remains byte-for-byte inert. Missing or malformed locks are -# uncertainty rather than stale-owner evidence and remain inert. +# uncertainty rather than recoverable-owner evidence and remain inert. RECOVER_SESSION_LOCK=0 if ! fm_session_lock_owned_by_self "$STATE"; then LOCK_PID=$(cat "$STATE/.lock" 2>/dev/null || true) case "$LOCK_PID" in ''|*[!0-9]*) exit 0 ;; esac - fm_harness_pid_alive "$LOCK_PID" && exit 0 + fm_session_lock_holder_competes "$LOCK_PID" && exit 0 RECOVER_SESSION_LOCK=1 fi diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4..9249bd8385 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -3,6 +3,15 @@ # 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. +# +# Acquisition yields ONLY to a genuinely competing session, decided by +# fm_session_lock_holder_competes in bin/fm-session-lock-lib.sh: a recorded +# holder that is this same session in another process tree, or a durably +# suspended one, does not block. Every acquisition then converges the lock onto +# the acquiring session's own pid, so repeated runs are idempotent, and any live +# holder it converged onto, took over from, or could not identify is named on +# stdout rather than being silent. +# # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u @@ -29,7 +38,13 @@ if [ "${1:-}" = "status" ]; then echo "lock: unreadable" exit 0 } - if fm_harness_pid_alive "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi + if ! fm_harness_pid_alive "$old"; then + echo "lock: stale (pid $old dead or not a harness)" + elif fm_harness_pid_suspended "$old"; then + echo "lock: held by SUSPENDED harness pid $old (reclaimable: a stopped session is not holding this home)" + else + echo "lock: held by live harness pid $old" + fi exit 0 fi @@ -55,13 +70,17 @@ release_claim_lock() { trap release_claim_lock EXIT trap 'exit 1' HUP INT TERM +# Why the yield reason is captured rather than printed here: the fast path +# below is only a pre-check, and the authoritative decision is retaken under the +# claim lock. Printing it once, at the end, keeps one acquisition to one line. +YIELDED= 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 fi - if fm_harness_pid_alive "$old"; then + if fm_session_lock_holder_competes "$old"; then echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 exit 1 fi @@ -86,9 +105,22 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then echo "error: session lock is unreadable; operate read-only until resolved" >&2 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 - exit 1 + if [ "$old" != "$me" ]; then + if fm_session_lock_holder_competes "$old"; then + echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 + exit 1 + fi + # Reclaiming an owner that is gone has always been silent and stays that + # way. The three holders that are alive and still yield are the ones worth + # naming - this session's own holder in another tree, a durably suspended + # session, and a live process the identity rules cannot type as a harness - + # so name them rather than moving the lock out from under a visible process + # quietly. None of the three is exceptional; the last is the ordinary + # reading of a stale lock whose pid an unrelated process now occupies. + # The predicate supplies the whole clause and leaves it empty for the silent + # case, so this is one assignment rather than a second liveness question that + # could disagree with the classification that just ran. + YIELDED=$FM_SESSION_HOLDER_YIELD_REASON fi fi if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then @@ -104,4 +136,8 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then exit 1 fi release_claim_lock -echo "lock acquired: harness pid $me" +if [ -n "$YIELDED" ]; then + echo "lock acquired: harness pid $me ($YIELDED)" +else + echo "lock acquired: harness pid $me" +fi diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index d77e563f0b..0733fdb812 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -2,11 +2,36 @@ # 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 is the current process part of that same session?" 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. +# bin/fm-claude-stop-autoarm.sh and bin/fm-turnend-guard-cursor.sh use it to +# prove a turn-end hook fires inside the lock-owning primary session before it +# may arm or rewake. # This file is sourced by scripts and has no side effects on source. +# +# Two independent kinds of same-session evidence are accepted, because the +# process tree alone is not the session: +# +# Ancestry (below): the lock names a process in this one's contiguous +# verified-harness ancestry. This is the original evidence and still the +# primary one. +# +# Session cohort (further below): the lock names a live harness process tied to +# this one by a launch relationship in EITHER direction - it started this +# session, or this session started it - and co-located with it. A harness can +# put a session's work in a process tree that never reaches the pid holding the +# lock - a background session rehosted under its own pty reparents to init - +# and ancestry then reports one genuine session as two competing ones. +# +# Neither kind vetoes the other: each is a positive proof on its own, and the +# absence of cohort evidence leaves the ancestry verdict exactly as it was. +# Within the cohort proof the signals are AND-ed, not OR-ed, because the one +# property that must survive is that a genuinely separate concurrent session is +# still refused. +# +# The cohort proof is per-harness and currently reaches only Claude; every other +# adapter is decided by ancestry alone. FM_SESSION_LAUNCH_MARKERS below owns that +# scope limit, why it is safe, and what extending it requires. # Cursor process identity is NOT expressible as a command-name pattern and is # deliberately not added to the tables below: Cursor's installed names are @@ -45,6 +70,107 @@ fm_harness_path_name() { # return 1 } +# Print the exact harness name when path $1's BASENAME is exactly a verified +# harness name, or return 1. +# +# The strictest of this file's rules, and the single owner of the exact-equality +# test. Two callers reach it, and they hand it different things. The `MainThread` +# branch below passes a SCRIPT PATH from argv, its only token of evidence, so it +# takes the strictest reading of it; the sibling interpreter branch reads the same +# token positions under the same stop-at-the-first-flag rule but by whole path +# component, because the only shape it identifies at all is a node-hosted bin +# entry that is NOT named after the harness. fm_harness_comm_name below passes a +# REPORTED COMMAND NAME, and normalizes the reporter's own artifacts out of it +# first; that normalization is stated there and deliberately does not reach the +# script-path caller, which sees neither of those artifacts. +# +# Exact equality is deliberate, and under `MainThread` it is the ONLY evidence +# there is: the command path is literally `MainThread` and argv[0] is the +# interpreter, so neither the command-path nor the argv[0] rule above can see the +# script at all. A harness whose script basename is not exactly the harness name +# - a version-suffixed name, or a `.js` bin entry - is therefore deliberately not +# identified in that branch. Teaching it such a shape is a verification task +# against a real release, the same as extending FM_SESSION_LAUNCH_MARKERS, and +# not a reason to loosen this rule. +fm_harness_basename_name() { # + local path=$1 base name + [ -n "$path" ] || return 1 + base=${path##*/} + for name in "${FM_HARNESS_NAMES[@]}"; do + if [ "$base" = "$name" ]; then + printf '%s' "$name" + return 0 + fi + done + return 1 +} + +# Print the exact harness name that COMMAND NAME $1 reports, or return 1. +# +# The same exact-equality test as fm_harness_basename_name, applied to a command +# name after two reporting artifacts are undone. Both are properties of how the +# name reaches us rather than of the program, so leaving them in place refuses a +# harness its own home while it is the only session alive: +# +# 1. A command name is not always one word, and it is truncated to 15 +# characters. Linux reports the kernel task name, which node and Bun +# harnesses rename to label a worker role, so Claude Code's background pty +# worker `claude bg-pty-host` arrives as `claude bg-pty-h` - one executable +# word, one role label, and a cut that lands mid-word. The executable is the +# FIRST word, so only that word is compared and the cut cannot reach it: no +# verified harness name is longer than 15 characters. +# 2. Claude Code's installed executable is literally named `claude.exe` on +# every platform, because it is a single-file Bun build, so a process that +# execs it reports that name verbatim. `.exe` is the only executable suffix +# any verified harness carries. +# +# Neither step loosens the comparison itself, which is still exact equality +# against FM_HARNESS_NAMES, and that is what keeps this from becoming a prefix +# rule: `claude-code` has neither a space nor a suffix, so it is still not a +# harness, and neither are `claudette` or `claude_code`. Path components are +# untouched here, so `pi` keeps the basename anchoring that stops an interior +# `/home/pi` component from naming a harness. +fm_harness_comm_name() { # + local base=${1##*/} + base=${base%% *} + base=${base%.exe} + fm_harness_basename_name "$base" +} + +# Print the exact harness name carried by an ARGV token $1, or return 1. +# +# A whole path component, as fm_harness_path_name reads it, except that `pi` and +# `pi-signed` must be the token's own basename. FM_HARNESS_RE anchors those two +# names as `^pi$` and `^pi-signed$` because they are too short and too ordinary +# to survive an unanchored reading, and a two-character interior component is +# exactly where that bites: `/home/pi` is the default home directory on Raspberry +# Pi OS, so `node /home/pi/app.js` would otherwise be a verified Pi harness, and +# as a recycled recorded holder it would refuse a real session its own home. +# The command path and argv[0] rule above reads those names as components too and +# is deliberately left alone here; this anchoring covers only the argv tokens, +# which the rule this replaced could never match for these two names at all. +fm_harness_argv_path_name() { # + local path=$1 base name + [ -n "$path" ] || return 1 + base=${path##*/} + for name in "${FM_HARNESS_NAMES[@]}"; do + case "$name" in + pi|pi-signed) + if [ "$base" = "$name" ]; then + printf '%s' "$name" + return 0 + fi + ;; + *) + case "/$path/" in + */"$name"/*) printf '%s' "$name"; return 0 ;; + esac + ;; + esac + done + return 1 +} + # True when the process described by command name $1 and full argument string $2 # is a verified harness. Sets FM_HARNESS_IS_CLAUDE for the ancestry walk. # @@ -55,11 +181,27 @@ fm_harness_path_name() { # # argv[0] in `ps -o comm=`, while procps on Linux reports the kernel exec # name and ignores argv[0] entirely, so a version-named Claude Code binary # is identified by its install path on macOS and by argv[0] on Linux. -# 3. a bare interpreter (node, python) running a harness script path. -# 4. Cursor's own structural identity, owned by bin/fm-cursor-lib.sh. +# 3. a bare interpreter (node, python) running a harness script path, taken +# from the argv tokens before the first flag by whole path component. +# 4. node's own `MainThread` exec name, resolved from the one argv token after +# the interpreter by exact basename only, and refused outright when that +# token is a flag. +# 5. Cursor's own structural identity, owned by bin/fm-cursor-lib.sh. +# +# Rules 3 and 4 read an ARGUMENT LIST, which is data the process was handed rather +# than the program it is running, so they identify a recorded pid for the ancestry +# walk and the liveness predicate and they carry no weight in the cohort's +# acceptance decision. fm_harness_exec_kind below is that stricter reading, and +# the difference between the two is stated there. +# +# This answers WHETHER, not WHICH. FM_HARNESS_IS_CLAUDE is the one thing it also +# reports, because the ancestry walk needs it to know when to keep climbing a +# nested worker chain; it is a global every call clobbers. Which harness a +# particular pid IS belongs to fm_harness_exec_kind below, which reads only +# executable identity and is the only naming the cohort proof may act on. FM_HARNESS_IS_CLAUDE=0 fm_harness_process_matches() { # - local comm=$1 args=$2 base argv0 name + local comm=$1 args=$2 base argv0 name script rest FM_HARNESS_IS_CLAUDE=0 base=$(basename -- "$comm") if printf '%s' "$base" | grep -qE "$FM_HARNESS_RE"; then @@ -71,23 +213,175 @@ fm_harness_process_matches() { # case "$name" in claude) FM_HARNESS_IS_CLAUDE=1 ;; esac return 0 fi - # Bare interpreter (e.g. node): match the harness name in its script path. + # Bare interpreter (e.g. node, python) that reports its OWN name as the exec + # name: identity comes from the interpreter's script path in argv, read under + # the same discipline the MainThread branch below uses. The tokens after the + # interpreter are read in order and the scan STOPS at the first one beginning + # with `-`, because from there on a path-shaped token may be an interpreter flag + # or the VALUE of one, and a flag's value can be named anything at all. A token + # before that boundary carries a verdict only when a whole path component of it + # is exactly a harness name. + # + # Reading the whole argument string is what this rule used to do, and it + # identified shapes that are not harnesses at all, because that test was an + # unanchored regex with no path-component requirement in it: an unrelated + # service carrying any ordinary `~/.claude/...` hook, settings, log or + # transcript argument reported itself as Claude, as did + # `node --require /opt/hooks/claude/instrument.js /srv/app/server.js` and + # `python3 /srv/app.py --config /etc/claude/x.toml`. None of the three is + # identified now. + # + # Stopping at the first flag gives up the inferred + # `node --experimental-foo /path/to/claude/cli.js` shape on purpose, which is + # the same trade the MainThread branch takes: the alternative is an allowlist of + # value-taking interpreter flags that would rot silently every time a vendor + # adds one, and silently stale recorded state is the failure this whole + # mechanism exists to remove. Both outputs are derived from the token that + # matched and never from the whole argv, so a harness name sitting elsewhere in + # the arguments can neither name the kind nor raise the Claude flag. + # + # Two limits of this rule are known and stated rather than left implied. It + # reads EVERY token before that flag rather than the script token alone, so a + # positional path argument can decide the verdict and this rule and the + # MainThread branch disagree on identical argv: `node /srv/app/server.js + # /opt/claude/agent.json` is Claude here and is nothing there. And the component + # it needs is an exact one, so a real npm layout whose package directory is + # `claude-code` or `opencode-ai` rather than the bare harness name is not + # identified at all. Both bound identification for the ancestry walk and the + # liveness predicate only; neither can reach the cohort's acceptance decision, + # which fm_harness_exec_kind below settles from executable identity alone. + # + # The npm-layout limit runs in BOTH directions, and the second direction is + # stated here because this lands on a running fleet by in-place update. The + # liveness predicate also judges a RECORDED holder, so a session lock an older + # firstmate wrote for a still-live session of one of those unidentified shapes + # now reads as not-a-harness and is reclaimed while that session is still + # running. It is bounded to that adoption window rather than being a standing + # hole, because this rule refuses to record such a holder in the first place, + # and it is not closed. + # + # That shape is one INSTANCE of a wider condition rather than its cause. Any + # live process occupying the recorded pid that these rules cannot type reads + # the same way, and the ordinary trigger is an unrelated process that inherited + # a recycled pid from a lock nobody released. fm_harness_pid_state's header + # below is the single owner of that condition rather than this block restating + # it. The mitigation covers all of it: fm_session_lock_holder_competes + # classifies a live-but-unidentified holder apart from a gone one, so the + # reclaim announces itself rather than moving the lock out from under a visible + # process in silence. case "$comm" in *node*|*python*) - if printf '%s' "$args" | grep -qE "$FM_HARNESS_RE"; then - case "$args" in *claude*) FM_HARNESS_IS_CLAUDE=1 ;; esac - return 0 + rest=${args#* } + if [ "$rest" != "$args" ]; then + while [ -n "$rest" ]; do + script=${rest%% *} + case "$rest" in + *' '*) rest=${rest#* } ;; + *) rest='' ;; + esac + [ -n "$script" ] || continue + case "$script" in + -*) break ;; + esac + if name=$(fm_harness_argv_path_name "$script"); then + case "$name" in claude) FM_HARNESS_IS_CLAUDE=1 ;; esac + return 0 + fi + done fi ;; esac + # Node renames its own main thread, so an npm-installed harness can report + # `MainThread` as its exec name and carry no interpreter name for the case + # above to catch: codex-cli 0.139.0 under nvm on Linux reports comm + # `MainThread` with argv `node .../bin/codex`, and without this it is not a + # harness at all, which leaves a codex primary unable to acquire its own home. + # + # Identity then has to come from the interpreter's SCRIPT PATH, and the ONLY + # candidate is the single token immediately after the interpreter, matched by + # the strictest rule this file has, exact basename. `MainThread` is a name any + # node program can present, and the rest of an interpreter's argv is not the + # script: it also carries the values of the interpreter's own flags, and those + # values are paths that can be named anything, `/opt/vendor/claude` included. + # + # So the branch REFUSES to identify anything as soon as that first token is a + # flag, rather than trying to work out where the flags end. An interpreter that + # reports its own name is decided by the rule above, which stops at the first + # flag in the same way but reads every token up to it, by whole path component + # rather than by basename. Neither reading reaches the cohort's acceptance + # decision, which fm_harness_exec_kind settles from executable identity alone. + # + # The refusal gives up the inferred `node --enable-source-maps .../bin/codex` + # shape on purpose: the alternative is an allowlist of value-taking interpreter + # flags, which would rot silently every time a vendor adds one, and silently + # stale recorded state is the exact failure this whole mechanism exists to + # remove. Refusing to guess is the intended behaviour, and only the plain + # `node