diff --git a/bin/fm-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index 89ce011f6bb..cccbbfdfa8b 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -107,7 +107,7 @@ fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 # idle or away home remains byte-for-byte inert. Missing or malformed locks are # uncertainty rather than stale-owner evidence and remain inert. RECOVER_SESSION_LOCK=0 -if ! fm_session_lock_owned_by_self "$STATE"; then +if ! fm_session_lock_owned_by_current_session "$STATE"; then LOCK_PID=$(cat "$STATE/.lock" 2>/dev/null || true) case "$LOCK_PID" in ''|*[!0-9]*) exit 0 ;; @@ -131,7 +131,7 @@ need_supervision || exit 0 # before touching any auto-arm state. if [ "$RECOVER_SESSION_LOCK" -eq 1 ]; then "$SCRIPT_DIR/fm-lock.sh" >/dev/null 2>&1 || exit 0 - fm_session_lock_owned_by_self "$STATE" || exit 0 + fm_session_lock_owned_by_current_session "$STATE" || exit 0 fi # --- single-flight owner claim ------------------------------------------------ diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..0911d2ce99b 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Acquire or inspect the per-home firstmate session lock. -# Writes the harness (agent) process PID found by walking the shell's ancestry, -# which lives as long as the firstmate session - unlike the transient subshell -# PID of any one tool call, which is dead moments after it is written. +# Writes a verified session identity beside the compatible numeric lock pid. +# Claude's binding is independent of its reparented worker-pool ancestry, so a +# sibling session cannot claim the same home through that shared pool. # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u @@ -33,7 +33,11 @@ if [ "${1:-}" = "status" ]; then exit 0 fi -me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } +fm_session_lock_prepare_acquisition_identity || { + echo "error: cannot establish this session's lock identity; operate read-only until resolved" >&2 + exit 1 +} +me=$FM_SESSION_LOCK_OWNER_PID probe=$(mktemp "$STATE/.lock-write.XXXXXX" 2>/dev/null) || { echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 @@ -57,8 +61,8 @@ trap 'exit 1' HUP INT TERM if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then old=$(cat "$LOCK" 2>/dev/null || true) - if [ "$old" = "$me" ]; then - echo "lock acquired: harness pid $me" + if fm_session_lock_owned_by_current_session "$STATE"; then + echo "lock acquired: harness pid $old" exit 0 fi if fm_harness_pid_alive "$old"; then @@ -86,12 +90,17 @@ 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 + if fm_session_lock_owned_by_current_session "$STATE"; then + release_claim_lock + echo "lock acquired: harness pid $old" + exit 0 + 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 fi fi -if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then +if ! fm_session_lock_write_new_format "$STATE"; then echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 fi diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 364e28e3cfb..083a26b09b4 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -68,6 +68,10 @@ fm_harness_path_name() { # # 3. a bare interpreter (node, python) running a harness script path. # 4. Cursor's own structural identity, owned by bin/fm-cursor-lib.sh. FM_HARNESS_IS_CLAUDE=0 +FM_HARNESS_ANCESTRY_CACHE_READY=0 +FM_HARNESS_ANCESTRY_CACHE_FOUND=0 +FM_HARNESS_ANCESTRY_PIDS= +FM_HARNESS_ANCESTRY_IS_CLAUDE=0 fm_harness_process_matches() { # local comm=$1 args=$2 base argv0 name FM_HARNESS_IS_CLAUDE=0 @@ -116,14 +120,23 @@ fm_harness_process_matches() { # # 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. -fm_harness_ancestry_pids() { +fm_harness_ancestry_cache() { local pid=$$ comm args extending=0 printed=0 + if [ "$FM_HARNESS_ANCESTRY_CACHE_READY" -eq 1 ]; then + [ "$FM_HARNESS_ANCESTRY_CACHE_FOUND" -eq 1 ] + return + fi + FM_HARNESS_ANCESTRY_CACHE_READY=1 + FM_HARNESS_ANCESTRY_CACHE_FOUND=0 + FM_HARNESS_ANCESTRY_PIDS= + FM_HARNESS_ANCESTRY_IS_CLAUDE=0 for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) if fm_harness_process_matches "$comm" "$args"; then - printf '%s\n' "$pid" + FM_HARNESS_ANCESTRY_PIDS="${FM_HARNESS_ANCESTRY_PIDS}${FM_HARNESS_ANCESTRY_PIDS:+$'\n'}$pid" printed=1 + [ "$FM_HARNESS_IS_CLAUDE" -eq 0 ] || FM_HARNESS_ANCESTRY_IS_CLAUDE=1 [ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || break extending=1 elif [ "$extending" -eq 1 ]; then @@ -132,7 +145,13 @@ fm_harness_ancestry_pids() { pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') [ -n "$pid" ] && [ "$pid" -gt 1 ] || break done - [ "$printed" -eq 1 ] + [ "$printed" -eq 1 ] || return 1 + FM_HARNESS_ANCESTRY_CACHE_FOUND=1 +} + +fm_harness_ancestry_pids() { + fm_harness_ancestry_cache || return 1 + printf '%s\n' "$FM_HARNESS_ANCESTRY_PIDS" } # Print the one pid that identifies this session when the session lock is being @@ -142,12 +161,12 @@ fm_harness_ancestry_pids() { # is still running. Every non-Claude harness reports a single pid, so this is its # innermost match unchanged. fm_harness_ancestry_pid() { - local pids pid outermost='' - pids=$(fm_harness_ancestry_pids) || return 1 + local pid outermost='' + fm_harness_ancestry_cache || return 1 while IFS= read -r pid; do [ -n "$pid" ] && outermost=$pid done </dev/null || true) case "$lock_pid" in ''|*[!0-9]*) return 1 ;; esac - pids=$(fm_harness_ancestry_pids) || return 1 + fm_harness_ancestry_cache || return 1 while IFS= read -r pid; do [ "$pid" = "$lock_pid" ] && return 0 done < + printf '%s/.lock.session\n' "$1" +} + +# Read one exact new-format lock binding into FM_SESSION_LOCK_RECORD_*. +# A missing binding is legacy. A malformed binding is never legacy, because a +# failed or tampered new-format record must not regain the compatibility path. +FM_SESSION_LOCK_RECORD_KIND= +FM_SESSION_LOCK_RECORD_PID= +FM_SESSION_LOCK_RECORD_SESSION= +fm_session_lock_read_record_file() { # + local path=$1 first second third fourth extra + FM_SESSION_LOCK_RECORD_KIND= + FM_SESSION_LOCK_RECORD_PID= + FM_SESSION_LOCK_RECORD_SESSION= + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + { + IFS= read -r first + IFS= read -r second + IFS= read -r third + IFS= read -r fourth + IFS= read -r extra || true + } < "$path" || return 1 + [ "$first" = 'format=1' ] || return 1 + case "$second" in kind=claude|kind=ancestry) ;; *) return 1 ;; esac + case "$third" in pid=*) FM_SESSION_LOCK_RECORD_PID=${third#pid=} ;; *) return 1 ;; esac + case "$fourth" in session=*) FM_SESSION_LOCK_RECORD_SESSION=${fourth#session=} ;; *) return 1 ;; esac + [ -z "$extra" ] || return 1 + case "$FM_SESSION_LOCK_RECORD_PID" in ''|*[!0-9]*) return 1 ;; esac + case "$FM_SESSION_LOCK_RECORD_SESSION" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + FM_SESSION_LOCK_RECORD_KIND=${second#kind=} +} + +fm_session_lock_read_record() { # + fm_session_lock_read_record_file "$(fm_session_lock_record_path "$1")" +} + +# Print state dir $1's validated binding in its on-disk format. +fm_session_lock_print_record() { # + fm_session_lock_read_record "$1" || return 1 + printf 'format=1\nkind=%s\npid=%s\nsession=%s\n' \ + "$FM_SESSION_LOCK_RECORD_KIND" "$FM_SESSION_LOCK_RECORD_PID" "$FM_SESSION_LOCK_RECORD_SESSION" +} + +# True when record file $2 is exactly state dir $1's validated lock binding. +fm_session_lock_record_matches_file() { # + local state=$1 record=$2 kind pid session + fm_session_lock_read_record "$state" || return 1 + kind=$FM_SESSION_LOCK_RECORD_KIND + pid=$FM_SESSION_LOCK_RECORD_PID + session=$FM_SESSION_LOCK_RECORD_SESSION + fm_session_lock_read_record_file "$record" || return 1 + [ "$FM_SESSION_LOCK_RECORD_KIND" = "$kind" ] \ + && [ "$FM_SESSION_LOCK_RECORD_PID" = "$pid" ] \ + && [ "$FM_SESSION_LOCK_RECORD_SESSION" = "$session" ] +} + +fm_session_lock_record_matches() { # + local state=$1 kind=$2 pid=$3 session=$4 + fm_session_lock_read_record "$state" || return 1 + [ "$FM_SESSION_LOCK_RECORD_KIND" = "$kind" ] \ + && [ "$FM_SESSION_LOCK_RECORD_PID" = "$pid" ] \ + && [ "$FM_SESSION_LOCK_RECORD_SESSION" = "$session" ] +} + +fm_session_lock_print_binding() { # + local kind=$1 pid=$2 session=$3 + case "$kind" in claude|ancestry) ;; *) return 1 ;; esac + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + case "$session" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + printf 'format=1\nkind=%s\npid=%s\nsession=%s\n' "$kind" "$pid" "$session" +} + +# Write the complete new lock format under fm-lock.sh's acquisition claim. +# The record publishes first, so readers fail closed while the raw pid moves; +# it is removed again if the raw lock cannot be replaced. A new acquisition +# therefore never leaves only a pid-only lock behind. +fm_session_lock_write_new_format() { # + local state=$1 path lock tmp_record tmp_lock previous= + [ -n "$FM_SESSION_LOCK_OWNER_KIND" ] || return 1 + [ -n "$FM_SESSION_LOCK_OWNER_PID" ] || return 1 + [ -n "$FM_SESSION_LOCK_OWNER_SESSION" ] || return 1 + path=$(fm_session_lock_record_path "$state") + lock="$state/.lock" + tmp_record=$(mktemp "$state/.lock.session.XXXXXX" 2>/dev/null) || return 1 + tmp_lock=$(mktemp "$state/.lock.new.XXXXXX" 2>/dev/null) || { + command rm -f -- "$tmp_record" 2>/dev/null + return 1 + } + if ! { + printf 'format=1\nkind=%s\npid=%s\nsession=%s\n' \ + "$FM_SESSION_LOCK_OWNER_KIND" "$FM_SESSION_LOCK_OWNER_PID" "$FM_SESSION_LOCK_OWNER_SESSION" > "$tmp_record" + printf '%s\n' "$FM_SESSION_LOCK_OWNER_PID" > "$tmp_lock" + }; then + command rm -f -- "$tmp_record" "$tmp_lock" 2>/dev/null + return 1 + fi + if [ -f "$path" ] && [ ! -L "$path" ]; then + previous=$(mktemp "$state/.lock.session.previous.XXXXXX" 2>/dev/null) || { + command rm -f -- "$tmp_record" "$tmp_lock" 2>/dev/null + return 1 + } + if ! cp "$path" "$previous" 2>/dev/null; then + command rm -f -- "$tmp_record" "$tmp_lock" "$previous" 2>/dev/null + return 1 + fi + fi + if ! mv -f "$tmp_record" "$path" 2>/dev/null; then + command rm -f -- "$tmp_record" "$tmp_lock" "$previous" 2>/dev/null + return 1 + fi + if ! mv -f "$tmp_lock" "$lock" 2>/dev/null; then + if [ -n "$previous" ]; then + mv -f "$previous" "$path" 2>/dev/null || true + else + command rm -f -- "$path" 2>/dev/null + fi + command rm -f -- "$tmp_lock" "$previous" 2>/dev/null + return 1 + fi + command rm -f -- "$previous" 2>/dev/null || true +} + +# Record every temporary legacy acceptance durably. Logging failure rejects the +# acceptance rather than silently extending the migration exception. +fm_session_lock_log_legacy_acceptance() { # + local state=$1 pid=$2 home + home=$(cd "$state/.." 2>/dev/null && pwd -P) || home=$state + printf 'legacy session lock accepted: home=%s pid=%s\n' "$home" "$pid" \ + >> "$state/.lock.legacy.log" 2>/dev/null +} + +# True only when state dir $1's existing PID-only lock is temporarily accepted +# for the current session. Follow-up task fm-remove-legacy-lock-compat removes +# this compatibility path once all live homes have turned over to new-format +# locks. It exists solely to avoid wedging a live home whose old lock names the +# shared Claude pool daemon during migration. +fm_session_lock_owned_by_legacy_compatibility() { # + local state=$1 lock_pid path ancestry_pid + fm_session_lock_prepare_acquisition_identity || return 1 + path=$(fm_session_lock_record_path "$state") + [ ! -e "$path" ] && [ ! -L "$path" ] || return 1 + lock_pid=$(cat "$state/.lock" 2>/dev/null || true) + case "$lock_pid" in ''|*[!0-9]*) return 1 ;; esac + ancestry_pid=$(fm_harness_ancestry_pid) || return 1 + if [ "$lock_pid" != "$FM_SESSION_LOCK_OWNER_PID" ] && [ "$lock_pid" != "$ancestry_pid" ]; then + return 1 + fi + fm_harness_pid_alive "$lock_pid" || return 1 + fm_session_lock_log_legacy_acceptance "$state" "$lock_pid" +} + +# True when the current session owns state dir $1's lock. New-format records +# prove Claude ownership with the session identity that survives worker-pool +# reparenting. A PID-only record reaches only the temporary, logged migration +# path above and can never be used to create a new lock. +fm_session_lock_owned_by_current_session() { # + local state=$1 lock_pid + state=$1 + if fm_session_lock_read_record "$state"; then + fm_session_lock_prepare_acquisition_identity || return 1 + lock_pid=$(cat "$state/.lock" 2>/dev/null || true) + [ "$lock_pid" = "$FM_SESSION_LOCK_RECORD_PID" ] || return 1 + fm_harness_pid_alive "$lock_pid" || return 1 + [ "$FM_SESSION_LOCK_OWNER_KIND" = "$FM_SESSION_LOCK_RECORD_KIND" ] || return 1 + [ "$FM_SESSION_LOCK_OWNER_PID" = "$FM_SESSION_LOCK_RECORD_PID" ] || return 1 + [ "$FM_SESSION_LOCK_OWNER_SESSION" = "$FM_SESSION_LOCK_RECORD_SESSION" ] + return + fi + fm_session_lock_owned_by_legacy_compatibility "$state" +} diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 99b81ba8abd..5259b300715 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -623,6 +623,9 @@ LOCK_OUT=$("$SCRIPT_DIR/fm-lock.sh" 2>&1) LOCK_RC=$? printf '%s\n' "$LOCK_OUT" READ_ONLY=0 +COMPLETION_LOCK_KIND= +COMPLETION_LOCK_PID= +COMPLETION_LOCK_SESSION= if [ "$LOCK_RC" -ne 0 ]; then READ_ONLY=1 BAR='●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' @@ -639,7 +642,15 @@ if [ "$LOCK_RC" -ne 0 ]; then printf '%s\n' "$BAR" } fi -REBUILDING_SESSION_PID=$(fm_harness_ancestry_pid 2>/dev/null || true) +if [ "$READ_ONLY" -eq 0 ] && fm_session_lock_read_record "$STATE"; then + COMPLETION_LOCK_KIND=$FM_SESSION_LOCK_RECORD_KIND + COMPLETION_LOCK_PID=$FM_SESSION_LOCK_RECORD_PID + COMPLETION_LOCK_SESSION=$FM_SESSION_LOCK_RECORD_SESSION +fi +REBUILDING_SESSION_PID= +if fm_session_lock_prepare_acquisition_identity 2>/dev/null; then + REBUILDING_SESSION_PID=$FM_SESSION_LOCK_OWNER_PID +fi print_agents_refresh_if_required "$REBUILDING_SESSION_PID" if [ "$READ_ONLY" -eq 0 ]; then @@ -947,13 +958,11 @@ EOF if [ "$READ_ONLY" -eq 0 ] && [ "$REEMIT" -eq 0 ]; then COMPLETION_RECORDED=0 - COMPLETION_PID=$(cat "$STATE/.lock" 2>/dev/null || true) - case "$COMPLETION_PID" in - ''|*[!0-9]*) COMPLETION_PID= ;; - esac + COMPLETION_PID=$COMPLETION_LOCK_PID COMPLETION_TMP=$(mktemp "$STATE/.session-start-complete.XXXXXX" 2>/dev/null || true) if [ -n "$COMPLETION_PID" ] && [ -n "$COMPLETION_TMP" ] \ - && printf '%s\n' "$COMPLETION_PID" > "$COMPLETION_TMP" 2>/dev/null \ + && fm_session_lock_record_matches "$STATE" "$COMPLETION_LOCK_KIND" "$COMPLETION_LOCK_PID" "$COMPLETION_LOCK_SESSION" \ + && fm_session_lock_print_binding "$COMPLETION_LOCK_KIND" "$COMPLETION_LOCK_PID" "$COMPLETION_LOCK_SESSION" > "$COMPLETION_TMP" \ && mv -f "$COMPLETION_TMP" "$COMPLETION_FILE" 2>/dev/null; then COMPLETION_RECORDED=1 else diff --git a/bin/fm-sessionstart-nudge.sh b/bin/fm-sessionstart-nudge.sh index fccf775dd95..a0236d30cf6 100755 --- a/bin/fm-sessionstart-nudge.sh +++ b/bin/fm-sessionstart-nudge.sh @@ -16,27 +16,13 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-primary-scope-lib.sh" # shellcheck source=bin/fm-operational-input.sh . "$SCRIPT_DIR/fm-operational-input.sh" +# shellcheck source=bin/fm-session-lock-lib.sh +. "$SCRIPT_DIR/fm-session-lock-lib.sh" fm_is_gate_agent "$FM_ROOT" && exit 0 fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 -lock_is_in_ancestry() { - local lock_pid pid=$$ _ - [ -f "$STATE/.lock" ] || return 1 - IFS= read -r lock_pid < "$STATE/.lock" 2>/dev/null || return 1 - case "$lock_pid" in - ''|*[!0-9]*|1) return 1 ;; - esac - kill -0 "$lock_pid" 2>/dev/null || return 1 - for _ in 1 2 3 4 5 6 7 8; do - [ "$pid" = "$lock_pid" ] && return 0 - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 - done - return 1 -} - -lock_is_in_ancestry && exit 0 +fm_session_lock_owned_by_current_session "$STATE" && exit 0 nudge= fm_operational_input_encode session-start \ "Run \`bin/fm-session-start.sh\` now, exactly once, before executing any other instructions." \ diff --git a/bin/fm-sessionstart-run.sh b/bin/fm-sessionstart-run.sh index 50496eef295..85dbe10f483 100755 --- a/bin/fm-sessionstart-run.sh +++ b/bin/fm-sessionstart-run.sh @@ -72,14 +72,10 @@ fm_is_gate_agent "$FM_ROOT" && exit 0 fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 session_start_completed() { - local lock_pid completion_pid [ -f "$STATE/.lock" ] && [ ! -L "$STATE/.lock" ] || return 1 [ -f "$COMPLETION_FILE" ] && [ ! -L "$COMPLETION_FILE" ] || return 1 - fm_session_lock_owned_by_self "$STATE" || return 1 - lock_pid=$(cat "$STATE/.lock" 2>/dev/null) || return 1 - completion_pid=$(cat "$COMPLETION_FILE" 2>/dev/null) || return 1 - case "$lock_pid" in ''|*[!0-9]*) return 1 ;; esac - [ "$completion_pid" = "$lock_pid" ] + fm_session_lock_owned_by_current_session "$STATE" || return 1 + fm_session_lock_record_matches_file "$STATE" "$COMPLETION_FILE" } if [ -z "$SOURCE" ] && [ ! -t 0 ]; then diff --git a/bin/fm-startup-network.sh b/bin/fm-startup-network.sh index 1909ce1bece..375f28bd2fe 100755 --- a/bin/fm-startup-network.sh +++ b/bin/fm-startup-network.sh @@ -197,25 +197,40 @@ phase_label() { # # --- start ------------------------------------------------------------------- cmd_start() { # - local locked=$1 harvest_pid=$2 lock_pid generation worker_pid phases started + local locked=$1 harvest_pid=$2 lock_pid lock_kind lock_session generation worker_pid phases started lease_held=0 mkdir -p "$STATE" 2>/dev/null || return 1 # Captured HERE, at the moment the caller still holds the lock, and carried to # the worker: re-reading the lock later would only prove that SOME session # holds it, which is exactly the case this guard exists to reject. - lock_pid=$(cat "$STATE/.lock" 2>/dev/null || true) - if [ "$locked" = 1 ] && ! fm_session_lock_owned_by_self "$STATE"; then - return 1 + if [ "$locked" = 1 ]; then + fm_lock_acquire_wait "$STATE/.lock.acquire" + lease_held=1 + lock_pid=$(cat "$STATE/.lock" 2>/dev/null || true) + if ! fm_session_lock_owned_by_current_session "$STATE"; then + fm_lock_release "$STATE/.lock.acquire" + return 1 + fi + if ! fm_session_lock_read_record "$STATE"; then + locked=0 + fi + else + lock_pid=$(cat "$STATE/.lock" 2>/dev/null || true) fi + lock_kind=${FM_SESSION_LOCK_RECORD_KIND:-} + lock_session=${FM_SESSION_LOCK_RECORD_SESSION:-} fm_lock_acquire_wait "$PUBLISH_LOCK" if [ "$(status_get state)" = running ] && worker_alive \ - && { [ "$locked" != 1 ] || [ "$(status_get lock_pid)" = "$lock_pid" ]; }; then + && [ "$(status_get lock_pid)" = "$lock_pid" ] \ + && [ "$(status_get lock_kind)" = "$lock_kind" ] \ + && [ "$(status_get lock_session)" = "$lock_session" ]; then # A worker from this or a previous session is still going. Starting a second # one would run the same mutating sweeps concurrently, so leave it alone and # let the harvest report its real state. generation=$(status_get generation) printf '%s\t%s\n' "$generation" "$harvest_pid" > "$CLAIM_FILE" 2>/dev/null || true fm_lock_release "$PUBLISH_LOCK" + [ "$lease_held" -eq 0 ] || fm_lock_release "$STATE/.lock.acquire" return 0 fi @@ -231,9 +246,12 @@ locked=$locked phases=$phases generation=$generation lock_pid=$lock_pid +lock_kind=$lock_kind +lock_session=$lock_session EOF then fm_lock_release "$PUBLISH_LOCK" + [ "$lease_held" -eq 0 ] || fm_lock_release "$STATE/.lock.acquire" return 1 fi @@ -254,6 +272,7 @@ EOF case $- in *m*) monitor_was_on=1 ;; esac set -m 2>/dev/null || true nohup "$SCRIPT_DIR/fm-startup-network.sh" run --locked "$locked" --lock-pid "$lock_pid" \ + --lock-kind "$lock_kind" --lock-session "$lock_session" \ --generation "$generation" \ >/dev/null 2>&1 /dev/null || true fm_lock_release "$PUBLISH_LOCK" + [ "$lease_held" -eq 0 ] || fm_lock_release "$STATE/.lock.acquire" [ "$monitor_was_on" -eq 1 ] || set +m 2>/dev/null || true return 1 fi printf '%s\t%s\n' "$generation" "$harvest_pid" > "$CLAIM_FILE" 2>/dev/null || true fm_lock_release "$PUBLISH_LOCK" + [ "$lease_held" -eq 0 ] || fm_lock_release "$STATE/.lock.acquire" [ "$monitor_was_on" -eq 1 ] || set +m 2>/dev/null || true return 0 } @@ -293,12 +316,18 @@ EOF # nobody else has claimed, and the sweeps are idempotent, so finishing it is # strictly better than abandoning it. A missing, unreadable, or replaced lock all # fail closed to the read-only probe. -lock_unchanged() { # - local expected=$1 current - case "$expected" in ''|*[!0-9]*) return 1 ;; esac +lock_unchanged() { # + local expected_pid=$1 expected_kind=$2 expected_session=$3 current + case "$expected_pid" in ''|*[!0-9]*) return 1 ;; esac + case "$expected_kind" in claude|ancestry) ;; *) return 1 ;; esac + case "$expected_session" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac [ -f "$STATE/.lock" ] && [ ! -L "$STATE/.lock" ] || return 1 current=$(cat "$STATE/.lock" 2>/dev/null) || return 1 - [ "$current" = "$expected" ] + [ "$current" = "$expected_pid" ] || return 1 + fm_session_lock_read_record "$STATE" || return 1 + [ "$FM_SESSION_LOCK_RECORD_KIND" = "$expected_kind" ] \ + && [ "$FM_SESSION_LOCK_RECORD_PID" = "$expected_pid" ] \ + && [ "$FM_SESSION_LOCK_RECORD_SESSION" = "$expected_session" ] } # Bootstrap owns the meaning of its output protocol: silence is success, @@ -398,14 +427,16 @@ locked=$locked phases=$phases generation=$generation lock_pid=$(status_get lock_pid) +lock_kind=$(status_get lock_kind) +lock_session=$(status_get lock_session) report_published=$report_published EOF fm_lock_release "$PUBLISH_LOCK" await_delivery "$generation" "$state" } -cmd_run() { # - local locked=$1 lock_pid=$2 generation=$3 phases started budget out rc sweep_locked=0 downgraded=0 internal=0 lease_held=0 timings stage_started +cmd_run() { # + local locked=$1 lock_pid=$2 lock_kind=$3 lock_session=$4 generation=$5 phases started budget out rc sweep_locked=0 downgraded=0 internal=0 lease_held=0 timings stage_started mkdir -p "$STATE" 2>/dev/null || return 1 started=$(now) budget=$(stage_budget) @@ -418,13 +449,21 @@ cmd_run() { # fi fm_lock_release "$PUBLISH_LOCK" [ "$internal" -eq 1 ] || return 1 - elif [ "$locked" = 1 ] && ! fm_session_lock_owned_by_self "$STATE"; then + elif [ "$locked" = 1 ] && ! fm_session_lock_owned_by_current_session "$STATE"; then downgraded=1 locked=0 fi if [ "$locked" = 1 ]; then - [ "$internal" -eq 1 ] || lock_pid=$(cat "$STATE/.lock" 2>/dev/null || true) - if lock_unchanged "$lock_pid"; then + if [ "$internal" -eq 0 ]; then + fm_session_lock_read_record "$STATE" || { + downgraded=1 + locked=0 + } + lock_pid=$FM_SESSION_LOCK_RECORD_PID + lock_kind=$FM_SESSION_LOCK_RECORD_KIND + lock_session=$FM_SESSION_LOCK_RECORD_SESSION + fi + if [ "$locked" = 1 ] && lock_unchanged "$lock_pid" "$lock_kind" "$lock_session"; then sweep_locked=1 phases=probe,sweeps else @@ -447,6 +486,8 @@ locked=$sweep_locked phases=$phases generation=$generation lock_pid=$lock_pid +lock_kind=$lock_kind +lock_session=$lock_session EOF fm_lock_release "$PUBLISH_LOCK" fi @@ -464,7 +505,7 @@ EOF if [ "$sweep_locked" -eq 1 ]; then fm_lock_acquire_wait "$STATE/.lock.acquire" lease_held=1 - if ! lock_unchanged "$lock_pid"; then + if ! lock_unchanged "$lock_pid" "$lock_kind" "$lock_session"; then sweep_locked=0 phases=probe downgraded=1 @@ -611,6 +652,8 @@ cmd_wait() { # LOCKED=0 HARVEST_PID= LOCK_PID= +LOCK_KIND= +LOCK_SESSION= GENERATION= MODE=${1:-} [ $# -eq 0 ] || shift @@ -619,6 +662,8 @@ while [ $# -gt 0 ]; do --locked) LOCKED=${2:-0}; shift; [ $# -eq 0 ] || shift ;; --harvest-pid|--pid) HARVEST_PID=${2:-}; shift; [ $# -eq 0 ] || shift ;; --lock-pid) LOCK_PID=${2:-}; shift; [ $# -eq 0 ] || shift ;; + --lock-kind) LOCK_KIND=${2:-}; shift; [ $# -eq 0 ] || shift ;; + --lock-session) LOCK_SESSION=${2:-}; shift; [ $# -eq 0 ] || shift ;; --generation) GENERATION=${2:-}; shift; [ $# -eq 0 ] || shift ;; -h|--help) usage; exit 0 ;; *) break ;; @@ -628,7 +673,7 @@ case "$LOCKED" in 0|1) ;; *) LOCKED=0 ;; esac case "$MODE" in start) cmd_start "$LOCKED" "${HARVEST_PID:-0}" ;; - run) cmd_run "$LOCKED" "$LOCK_PID" "$GENERATION" ;; + run) cmd_run "$LOCKED" "$LOCK_PID" "$LOCK_KIND" "$LOCK_SESSION" "$GENERATION" ;; harvest) cmd_harvest "${HARVEST_PID:-}" ;; report) print_state; print_timings ;; wait) cmd_wait "${1:-120}" || exit $? ;; diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 556349594ec..46679bc8810 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -155,7 +155,7 @@ family_for_basename() { ;; fm-daemon.test.sh|fm-guard-stale-banner.test.sh|fm-pi-watch-extension.test.sh|\ fm-opencode-secondmate-arm.test.sh|\ - fm-session-lock-ancestry.test.sh|fm-cursor-primary.test.sh|\ + fm-session-lock-ancestry.test.sh|fm-lock-ownership.test.sh|fm-cursor-primary.test.sh|\ fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\ fm-wake-drain-unread-status.test.sh|\ fm-tool-update-check.test.sh|\ diff --git a/bin/fm-turnend-guard-cursor.sh b/bin/fm-turnend-guard-cursor.sh index ed608d1b867..8c50eaf61fc 100755 --- a/bin/fm-turnend-guard-cursor.sh +++ b/bin/fm-turnend-guard-cursor.sh @@ -232,18 +232,18 @@ current_session_still_ours() { owner=$(cat "$STATE/.lock" 2>/dev/null) || return 1 case "$owner" in ''|*[!0-9]*) return 1 ;; esac [ "$owner" = "$OWNER_ID" ] || return 1 - fm_session_lock_owned_by_self "$STATE" + fm_session_lock_owned_by_current_session "$STATE" } # Only the lock-owning session may arm or wake. A prior session that died # leaving its numeric harness pid behind is the one recoverable # case, delegated to bin/fm-lock.sh so acquisition keeps its single owner. -if ! fm_session_lock_owned_by_self "$STATE"; then +if ! fm_session_lock_owned_by_current_session "$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 "$SCRIPT_DIR/fm-lock.sh" >/dev/null 2>&1 || exit 0 - fm_session_lock_owned_by_self "$STATE" || exit 0 + fm_session_lock_owned_by_current_session "$STATE" || exit 0 fi OWNER_ID=$(cat "$STATE/.lock" 2>/dev/null || true) diff --git a/docs/scripts.md b/docs/scripts.md index d495a465c60..88ff4f3f376 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -42,7 +42,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` `@AGENTS.md` pointer, and the canonical self-governance section | | `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and unhealthy supervision | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | -| `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | +| `fm-session-lock-lib.sh` | Shared session-lock identity verification, binding publication, and holder liveness for fm-lock.sh and the Claude Stop auto-arm | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index f27d7250293..f19f61cb819 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -32,9 +32,9 @@ This deliberately inverts the previous nudge matcher, which fired on `startup|re Compaction is covered where a tracked adapter delivers that source because a compacted session has lost exactly the digest it needs, and resume is excluded from the run because it restores that digest instead of losing it. Current harness ownership of the lock and its matching `state/.session-start-complete` record together are the idempotency interlock for the whole scheme. -The full digest clears that completion record after acquiring the lock and republishes the lock owner's pid only after every stage completes, so `clear` or `compact` cannot skip startup sweeps after a truncated run. -`bin/fm-lock.sh` already treats a lock this session's own harness holds as its own, so a proven `clear` or `compact` re-emit re-verifies ownership and proceeds, while a lock another live session took meanwhile still produces the ordinary read-only digest. -On a run-tier harness the nudge cannot also fire: `resume`, `reload`, and `fork` are the only sources routed to it, and on those its own ancestry check stays silent whenever this process already holds the lock. +The full digest clears that completion record after acquiring the lock and republishes the lock's complete identity binding only after every stage completes, so `clear` or `compact` cannot skip startup sweeps after a truncated run. +`bin/fm-lock.sh` re-verifies the current session's lock identity before a proven `clear` or `compact` re-emit proceeds, while a lock another live session took meanwhile still produces the ordinary read-only digest. +On a run-tier harness the nudge cannot also fire: `resume`, `reload`, and `fork` are the only sources routed to it, and on those the shared ownership verifier stays silent whenever this session already holds the lock. `bin/fm-session-start.sh --reemit` owns which work a re-emit skips, its true-start AGENTS.md baseline, and its supported stale-instruction refresh pairs; its header is the single owner of those mechanics. @@ -58,8 +58,8 @@ The Guard Predicates section of [`turnend-guard.md`](turnend-guard.md#guard-pred The nudge payload starts with U+2063 and the stable `FIRSTMATE_OP: ` label, carries the current `session-start` protocol kind, and retains exactly ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` as its body. The Ahoy skill owns the rule that this marked operational input is never a captain-authored session boundary, including its narrow legacy compatibility cases, and its own step 0 helm check is the fallback that protects a nudge-tier harness whose first command is a skill. -Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. -If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. +Before printing, the nudge wrapper delegates lock ownership to `fm_session_lock_owned_by_current_session()` in `bin/fm-session-lock-lib.sh`. +If that shared verifier confirms this harness session owns the lock, session start already ran and the wrapper stays silent. Every path in both wrappers exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization. A lock another session holds and a truncated digest therefore surface as digest text, while broken GitHub auth surfaces through the deferred network result inline or as a wake; none becomes a refusal to open the session. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 8ae889f30fe..fe6968be810 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` verifies a published identity binding rather than trusting Claude Code's reparented worker-pool ancestry. +Claude acquisition requires both its stable session ID and served session pid, while other supported harnesses retain their verified ancestry identity. 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-lock-ownership.test.sh` exercises new lock acquisition through `bin/fm-lock.sh`, proving same-session re-entry and sibling-session rejection after Claude worker-pool reparenting. +`tests/fm-session-lock-ancestry.test.sh` pins both platforms' harness 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-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-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index ff095c32912..ddcce9775c9 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -14,6 +14,15 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# Drop ambient Claude lock-identity markers leaked from the suite runner. A +# firstmate worker launched under Claude Code inherits CLAUDECODE and +# CLAUDE_CODE_SESSION_ID; leaving those set would let a fixture that models a +# missing or foreign identity look identifiable from the runner instead. Every +# fixture that models a genuine lock-owning Claude session supplies its own +# controlled identity (CLAUDECODE + a stable session id + the lock-owning +# fake-harness pid) explicitly below. +unset CLAUDECODE CLAUDE_CODE_SESSION_ID CLAUDE_PID + TMP_ROOT=$(fm_test_tmproot fm-claude-stop-autoarm) fm_git_identity fmtest fmtest@example.invalid @@ -72,6 +81,7 @@ run_autoarm() { local dir=$1 rc=0 printf '%s\n' '{"session_id":"sess-autoarm","stop_hook_active":false}' \ | FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + export CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="autoarm-session-$$" CLAUDE_PID=$$ printf "%s\n" "$$" > "$FM_HOME/state/.lock" "$FM_HOME/bin/fm-claude-stop-autoarm.sh" ' 2>&1 || rc=$? @@ -212,6 +222,7 @@ test_reclaims_stale_session_lock_before_arming() { write_arm_fixture "$dir" actionable out=$(printf '%s\n' '{"session_id":"stale"}' \ | FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + export CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="stale-session-$$" CLAUDE_PID=$$ printf "%s\n" "$$" > "$FM_HOME/state/expected-owner" "$FM_HOME/bin/fm-claude-stop-autoarm.sh" ' 2>&1); status=$? @@ -298,6 +309,7 @@ test_resolves_outermost_claude_pid_in_nested_bgspare_chain() { # collapse the two-hop chain this test depends on down to one hop. out=$(printf '%s\n' '{"session_id":"nested"}' \ | FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + export CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="nested-session-$$" CLAUDE_PID=$$ printf "%s\n" "$$" > "$FM_HOME/state/.lock" "$FAKE_CLAUDE" -c " printf \"%s\n\" \"\$\$\" > \"\$FM_HOME/state/inner-pid\" @@ -517,6 +529,7 @@ test_single_flight_admits_exactly_one_owner() { : > "$dir/state/task.meta" write_arm_fixture "$dir" slow-actionable FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + export CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="single-flight-session-$$" CLAUDE_PID=$$ printf "%s\n" "$$" > "$FM_HOME/state/.lock" printf "%s\n" "{\"session_id\":\"s\"}" | "$FM_HOME/bin/fm-claude-stop-autoarm.sh" >/dev/null 2>"$FM_HOME/state/err1" & p1=$! diff --git a/tests/fm-lock-ownership.test.sh b/tests/fm-lock-ownership.test.sh new file mode 100755 index 00000000000..2aa55e8111b --- /dev/null +++ b/tests/fm-lock-ownership.test.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# tests/fm-lock-ownership.test.sh - session-exclusive fleet-lock ownership. +# +# Each case invokes bin/fm-lock.sh rather than its sourced implementation. +# The fake process table models a live Claude session and a reparented shared +# worker pool, while real sleepers make the holder-liveness checks meaningful. +# +# Drop ambient Claude lock-identity markers leaked from the suite runner. +# A firstmate worker launched under Claude Code inherits CLAUDECODE and +# CLAUDE_CODE_SESSION_ID; leaving those set would make the fail-closed cases +# look identifiable and let them take the legacy compatibility path. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +unset CLAUDECODE CLAUDE_CODE_SESSION_ID CLAUDE_PID + +TMP_ROOT=$(fm_test_tmproot fm-lock-ownership) +PIDS= + +reap() { + local pid + for pid in $PIDS; do kill "$pid" 2>/dev/null || true; done + fm_test_cleanup +} +trap reap EXIT + +spawn_live_pid() { + sleep 300 >/dev/null 2>&1 & + LIVE_PID=$! + PIDS="$PIDS $LIVE_PID" +} + +make_home() { # + local home="$TMP_ROOT/$1" + mkdir -p "$home/state" + printf '%s\n' "$home" +} + +write_ps() { # + cat > "$1/ps" < + FM_LOCK_TEST_MODE=$3 CLAUDECODE=1 CLAUDE_CODE_SESSION_ID=$4 CLAUDE_PID=$5 \ + FM_HOME=$1 FM_STATE_OVERRIDE="$1/state" PATH="$2:$PATH" \ + bash "$ROOT/bin/fm-lock.sh" 2>&1 +} + +test_new_lock_excludes_a_pool_sibling_and_readmits_its_owner() { + local home fakebin session sibling spare host parent out + home=$(make_home new-lock) + fakebin=$(fm_fakebin "$home/bin") + spawn_live_pid; session=$LIVE_PID + spawn_live_pid; sibling=$LIVE_PID + spawn_live_pid; spare=$LIVE_PID + spawn_live_pid; host=$LIVE_PID + spawn_live_pid; parent=$LIVE_PID + write_ps "$fakebin" ignored "$session" "$sibling" "$spare" "$host" "$parent" + + out=$(run_lock "$home" "$fakebin" direct owner-session "$session") \ + || fail "new lock acquisition failed: $out" + [ "$(tr -d '[:space:]' < "$home/state/.lock")" = "$session" ] \ + || fail "new lock did not record the session pid" + [ "$(cat "$home/state/.lock.session")" = "$(printf 'format=1\nkind=claude\npid=%s\nsession=owner-session' "$session")" ] \ + || fail "new lock did not write its complete session binding" + + out=$(run_lock "$home" "$fakebin" pool owner-session "$session") \ + || fail "owner was refused after its call moved to the reparented pool: $out" + case "$out" in *"lock acquired: harness pid $session"*) ;; *) fail "owner re-entry did not report its original lock: $out" ;; esac + + out=$(run_lock "$home" "$fakebin" pool sibling-session "$sibling") \ + && fail "a sibling session acquired the owner's new-format lock: $out" + case "$out" in *"another live firstmate session holds the lock (pid $session)"*) ;; *) fail "sibling refusal was not explicit: $out" ;; esac + pass "fm-lock: a new session binding readmits its owner and rejects a sibling in the same pool" +} + +test_legacy_pool_lock_is_logged_when_temporarily_accepted() { + local home fakebin session sibling spare host parent out log + home=$(make_home legacy-lock) + fakebin=$(fm_fakebin "$home/bin") + spawn_live_pid; session=$LIVE_PID + spawn_live_pid; sibling=$LIVE_PID + spawn_live_pid; spare=$LIVE_PID + spawn_live_pid; host=$LIVE_PID + spawn_live_pid; parent=$LIVE_PID + write_ps "$fakebin" ignored "$session" "$sibling" "$spare" "$host" "$parent" + printf '%s\n' "$host" > "$home/state/.lock" + + out=$(run_lock "$home" "$fakebin" pool owner-session "$session") \ + || fail "a live legacy pool lock was not temporarily accepted: $out" + log=$(cat "$home/state/.lock.legacy.log" 2>/dev/null || true) + case "$log" in *"legacy session lock accepted: home="*" pid=$host"*) ;; *) fail "legacy acceptance was not logged with home and pid: $log" ;; esac + [ ! -e "$home/state/.lock.session" ] || fail "legacy acceptance rewrote a lock without a new acquisition" + pass "fm-lock: legacy shared-pool ownership is accepted only visibly during migration" +} + +test_unidentifiable_claude_session_cannot_use_legacy_compatibility() { + local home fakebin session sibling spare host parent out + home=$(make_home unidentifiable) + fakebin=$(fm_fakebin "$home/bin") + spawn_live_pid; session=$LIVE_PID + spawn_live_pid; sibling=$LIVE_PID + spawn_live_pid; spare=$LIVE_PID + spawn_live_pid; host=$LIVE_PID + spawn_live_pid; parent=$LIVE_PID + write_ps "$fakebin" ignored "$session" "$sibling" "$spare" "$host" "$parent" + printf '%s\n' "$host" > "$home/state/.lock" + + out=$(env -u CLAUDE_CODE_SESSION_ID \ + FM_LOCK_TEST_MODE=pool CLAUDECODE=1 CLAUDE_PID=$session FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + PATH="$fakebin:$PATH" bash "$ROOT/bin/fm-lock.sh" 2>&1) \ + && fail "an unidentifiable session fell through to legacy compatibility: $out" + case "$out" in *"cannot establish this session's lock identity"*) ;; *) fail "identity refusal was not explicit: $out" ;; esac + [ "$(tr -d '[:space:]' < "$home/state/.lock")" = "$host" ] \ + || fail "the unidentifiable session changed the legacy lock" + [ ! -e "$home/state/.lock.session" ] || fail "the unidentifiable session wrote a new-format binding" + pass "fm-lock: an unidentifiable new Claude acquisition refuses without legacy fallback" +} + +test_claude_without_its_session_marker_cannot_fall_back_to_ancestry() { + local home fakebin session sibling spare host parent out + home=$(make_home missing-marker) + fakebin=$(fm_fakebin "$home/bin") + spawn_live_pid; session=$LIVE_PID + spawn_live_pid; sibling=$LIVE_PID + spawn_live_pid; spare=$LIVE_PID + spawn_live_pid; host=$LIVE_PID + spawn_live_pid; parent=$LIVE_PID + write_ps "$fakebin" ignored "$session" "$sibling" "$spare" "$host" "$parent" + + out=$(env -u CLAUDECODE -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID \ + FM_LOCK_TEST_MODE=pool FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" PATH="$fakebin:$PATH" \ + bash "$ROOT/bin/fm-lock.sh" 2>&1) \ + && fail "a Claude worker without session identity acquired an ancestry lock: $out" + case "$out" in *"cannot establish this session's lock identity"*) ;; *) fail "missing marker refusal was not explicit: $out" ;; esac + [ ! -e "$home/state/.lock" ] || fail "an unidentifiable Claude worker wrote a lock" + pass "fm-lock: a Claude worker without session identity fails closed" +} + +test_new_lock_excludes_a_pool_sibling_and_readmits_its_owner +test_legacy_pool_lock_is_logged_when_temporarily_accepted +test_unidentifiable_claude_session_cannot_use_legacy_compatibility +test_claude_without_its_session_marker_cannot_fall_back_to_ancestry diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index d7ac74f3736..e9988ec77d6 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -266,6 +266,9 @@ if [ "${FM_FIXTURE_ORPHAN_HERE:-0}" = 1 ]; then done fi printf '%s\n' "$$" > "$FM_HOME/state/session-pid" +export CLAUDECODE=1 +export CLAUDE_CODE_SESSION_ID=fixture-session +export CLAUDE_PID=$$ printf '%s\n' "$$" > "$FM_HOME/state/.lock" "$FM_HOME/bin/fm-claude-stop-autoarm.sh" "$FM_HOME/state/hook.out" 2>&1 printf '%s\n' "$?" > "$FM_HOME/state/hook.rc" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 786aab89592..2e63b13a8aa 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -36,6 +36,13 @@ set -u # shellcheck source=tests/wake-helpers.sh . "$(dirname "${BASH_SOURCE[0]}")/wake-helpers.sh" +# Drop ambient Claude lock-identity markers leaked from the suite runner. A +# firstmate worker launched under Claude Code inherits CLAUDECODE and +# CLAUDE_CODE_SESSION_ID; leaving those set would let a session that means to +# model a missing or foreign identity look identifiable from the runner instead. +# Every claude-session invocation below supplies its own controlled identity. +unset CLAUDECODE CLAUDE_CODE_SESSION_ID CLAUDE_PID + SESSION_START="$ROOT/bin/fm-session-start.sh" BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} TMP_ROOT=$(fm_test_tmproot fm-session-start-tests) @@ -509,14 +516,34 @@ SH # codex and opencode have no env markers (ancestry only). Without this, a local # claude/pi/grok session fails cases that pin a different fake harness while CI # (no ambient markers) still passes. +# +# The claude path additionally supplies a controlled Claude session identity +# (CLAUDECODE + a stable session id + a live harness pid). The fleet lock binds +# a Claude session to that identity rather than to its reparentable worker-pool +# ancestry, so a claude session start cannot acquire the lock without it. The +# harness pid is the one the fake ps reports as claude - the pinned +# FM_FAKE_HARNESS_PID when a caller sets it, otherwise this live shell, which the +# unpinned fake ps reports as claude for every pid. run_session_start() { - local home=$1 root=$2 path=$3 pi_harness=${4:-} + local home=$1 root=$2 path=$3 pi_harness=${4:-} claude_pid if [ -n "$pi_harness" ]; then - env -u CLAUDECODE -u GROK_AGENT PI_CODING_AGENT=true FM_PI_HARNESS="$pi_harness" \ + env -u CLAUDECODE -u GROK_AGENT -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID \ + PI_CODING_AGENT=true FM_PI_HARNESS="$pi_harness" \ + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ + "$SESSION_START" + elif [ "${FM_FAKE_HARNESS:-claude}" = claude ]; then + claude_pid=${FM_FAKE_HARNESS_PID:-$$} + env -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="fm-test-session-$claude_pid" CLAUDE_PID="$claude_pid" \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ "$SESSION_START" else - env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + # A non-Claude harness pinned through FM_FAKE_HARNESS (pi, codex, ...) is + # identified from its fake ps ancestry and binds its lock to that single + # ancestry pid, so it needs no Claude identity markers - supplying them + # would misclassify it as Claude. Drop them exactly as the pi path does. + env -u CLAUDECODE -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID \ + -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ "$SESSION_START" fi @@ -911,6 +938,7 @@ SH done if FM_HOME="$home" FM_FAKE_LOCK_STATE="$home/state" \ FM_FAKE_HARNESS_PID="$harness_pid" PATH="$fakebin:$BASE_PATH" \ + CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="session-$harness_pid" CLAUDE_PID="$harness_pid" \ "$ROOT/bin/fm-lock.sh" >/dev/null 2>&1; then printf '%s\n' "$harness_pid" >> "$winners" fi @@ -1400,7 +1428,7 @@ EOF FM_HOME="$home" FM_SUPERVISION_ACTOR=branch FM_LEASE_HOLDER_PID=$$ "$ROOT/bin/fm-lease.sh" claim task-live --actor branch \ || fail "could not seed the live lease" - out=$(run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH") + out=$(FM_FAKE_HARNESS=pi run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH") assert_contains "$out" "BRANCH OUTCOMES (handled by the supervision branch, not yet seen by this session):" \ "locked start did not replay the unread branch outcome" assert_contains "$out" "https://example.com/pr/b" "replayed outcome lost its content" @@ -1409,7 +1437,7 @@ EOF # Replay is one-shot: presenting the digest is the delivery, so the next # locked start stays silent about the same outcome. - out=$(run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH") + out=$(FM_FAKE_HARNESS=pi run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH") case "$out" in *"BRANCH OUTCOMES"*) fail "second start re-presented already-replayed branch outcomes" ;; esac @@ -1979,9 +2007,14 @@ SH chmod +x "$nest" # shellcheck disable=SC2016 # $$ must expand in the launched shell, not here. + # The launched shell is the sole claude-named ancestor (the fake ps reports + # FM_FAKE_HARNESS_PID as claude), so it supplies the controlled Claude session + # identity the fleet lock now binds to: CLAUDECODE, a stable session id, and + # its own pid as the live served-session pid. Without it a claude session + # start fails closed instead of taking the lock. out=$(env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$fakebin:$BASE_PATH" \ - bash -c 'export FM_FAKE_HARNESS_PID=$$; exec "$1" 8 "$2"' _ "$nest" "$SESSION_START") + bash -c 'export FM_FAKE_HARNESS_PID=$$ CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="nested-ancestry-$$" CLAUDE_PID=$$; exec "$1" 8 "$2"' _ "$nest" "$SESSION_START") assert_contains "$out" "lock acquired: harness pid" \ "the runtime bound's wrapper processes pushed the harness out of the bounded ancestry walk" @@ -2014,8 +2047,14 @@ EOF "the full startup fixture did not exercise a mutating sweep" append_wake "$home/state" signal task-r "done: queued after the re-emit too" || fail "seed second wake failed" + # The re-emit re-enters the SAME session that ran the full startup above, so it + # presents that session's controlled Claude identity (CLAUDECODE + the stable + # session id run_session_start bound for FM_FAKE_HARNESS_PID=$$ + the live + # served pid). Without it the fleet lock fails closed, the run drops to + # read-only, and the queued wake is never drained. reemit=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_FAKE_HARNESS_PID=$$ PATH="$fakebin:$BASE_PATH" \ - env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + env -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="fm-test-session-$$" CLAUDE_PID=$$ \ "$SESSION_START" --reemit) assert_contains "$reemit" "SESSION START (CONTEXT RE-EMIT) - $home" "--reemit did not label itself" @@ -2233,8 +2272,14 @@ EOF make_fake_ps_claude "$fakebin" git -C "$root" checkout -q -B fm/reemit-tangle + # This re-emit acquires the lock fresh, so it presents a valid controlled + # Claude identity (CLAUDECODE + a stable session id + the live served pid). + # With the ambient markers unset at the top of this file, a stripped re-emit + # would fail closed on identity and mask the repair-ownership behavior under + # test; the identity lets it own the lock exactly as the feature intends. reemit=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$fakebin:$BASE_PATH" \ - env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + env -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="reemit-tangle-$$" CLAUDE_PID=$$ \ "$SESSION_START" --reemit) # A re-emit skips the sweeps because it ALREADY ran them, not because it lacks @@ -2248,8 +2293,12 @@ EOF sleep 300 & holder_pid=$! printf '%s\n' "$holder_pid" > "$home/state/.lock" + # The re-emit carries the SAME valid identity, but the lock pid now names a + # live foreign holder. Ownership re-verification must reject the mismatch and + # drop to read-only - a genuine ownership check, not an absence of identity. readonly_out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$fakebin:$BASE_PATH" \ - env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + env -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="reemit-tangle-$$" CLAUDE_PID=$$ \ "$SESSION_START" --reemit) kill "$holder_pid" 2>/dev/null || true wait "$holder_pid" 2>/dev/null || true diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index baa4a684624..1e2c58e0aec 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -33,6 +33,8 @@ NUDGE="$ROOT/bin/fm-sessionstart-nudge.sh" RUN="$ROOT/bin/fm-sessionstart-run.sh" # shellcheck source=/dev/null . "$ROOT/bin/fm-operational-input.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-session-lock-lib.sh" NUDGE_TEXT="Run \`bin/fm-session-start.sh\` now, exactly once, before executing any other instructions." fm_operational_input_encode session-start "$NUDGE_TEXT" NUDGE_LINE \ || fail "could not construct expected session-start nudge" @@ -126,16 +128,20 @@ test_missing_state_is_silent() { test_owned_lock_is_silent() { local root="$TMP_ROOT/already-ran" make_primary "$root" - printf '%s\n' "$$" > "$root/state/.lock" + fm_session_lock_prepare_acquisition_identity \ + || fail "could not establish the fixture session identity" + fm_session_lock_write_new_format "$root/state" \ + || fail "could not publish the fixture session lock" expect_silent_zero "owned lock nudge" run_nudge "$root" - pass "fm-sessionstart-nudge: a lock holder in process ancestry is already run" + pass "fm-sessionstart-nudge: a session holding a new-format lock is already run" } test_opencode_plugin_delivers_exact_nudge_once() { local root="$TMP_ROOT/opencode-primary" out status=0 make_primary "$root" cp "$ROOT/bin/fm-sessionstart-nudge.sh" "$ROOT/bin/fm-primary-scope-lib.sh" \ - "$ROOT/bin/fm-gate-refuse-lib.sh" "$ROOT/bin/fm-operational-input.sh" "$root/bin/" + "$ROOT/bin/fm-gate-refuse-lib.sh" "$ROOT/bin/fm-operational-input.sh" \ + "$ROOT/bin/fm-session-lock-lib.sh" "$ROOT/bin/fm-cursor-lib.sh" "$root/bin/" chmod +x "$root/bin/fm-sessionstart-nudge.sh" out=$(PLUGIN="$ROOT/.opencode/plugins/fm-primary-sessionstart-nudge.js" \ WORKTREE="$root" EXPECTED="$NUDGE_LINE" node --input-type=module 2>&1 <<'EOF' @@ -321,6 +327,22 @@ test_run_clear_rejects_previous_owner_completion() { pass "run wrapper: clear accepts completion only from the current harness" } +test_run_clear_rejects_a_pid_only_completion_record() { + local root="$TMP_ROOT/run-clear-pid-only-completion" out status=0 lock_pid + make_run_primary "$root" + run_hook "$root" --source startup /dev/null + lock_pid=$(cat "$root/state/.lock") + printf '%s\n' "$lock_pid" > "$root/state/.session-start-complete" + + out=$(run_hook "$root" --source clear /dev/null 2>&1 || { @@ -547,6 +569,7 @@ test_run_rebuild_forwards_source_to_drifted_instruction_refresh test_run_compact_without_completion_refreshes_before_finishing_startup test_run_clear_without_completion_finishes_startup test_run_clear_rejects_previous_owner_completion +test_run_clear_rejects_a_pid_only_completion_record test_run_resume_delegates_to_the_nudge test_run_reads_source_from_the_hook_payload test_run_unknown_source_takes_the_helm diff --git a/tests/fm-startup-network.test.sh b/tests/fm-startup-network.test.sh index e5f7be2e11e..9d351bdc15e 100755 --- a/tests/fm-startup-network.test.sh +++ b/tests/fm-startup-network.test.sh @@ -21,6 +21,13 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# Drop ambient Claude lock-identity markers leaked from the suite runner. A +# firstmate worker launched under Claude Code inherits CLAUDECODE and +# CLAUDE_CODE_SESSION_ID; this suite models an ancestry-kind (non-Claude) +# session, so leaving those set could let a claude-ancestry identity path +# activate against the ancestry bindings these tests write. +unset CLAUDECODE CLAUDE_CODE_SESSION_ID CLAUDE_PID + TMP_ROOT=$(fm_test_tmproot fm-startup-network-tests) FM_TEST_CLEANUP_DIRS+=("$TMP_ROOT") trap fm_test_cleanup EXIT @@ -73,8 +80,8 @@ for argument in "$@"; do done if [ "$pid" = "${FM_FAKE_HARNESS_PID:-}" ]; then case "$*" in - *comm=*) printf '/usr/local/bin/claude\n' ;; - *args=*) printf 'claude\n' ;; + *comm=*) printf '/usr/local/bin/codex\n' ;; + *args=*) printf 'codex\n' ;; *ppid=*) /bin/ps -o ppid= -p "$pid" ;; esac else @@ -118,6 +125,13 @@ run_stage() { # FM_HOME="$home" FM_ROOT_OVERRIDE="$root" "$root/bin/fm-startup-network.sh" "$@" } +write_lock_binding() { # [session] + local home=$1 pid=$2 session=${3:-$2} + printf '%s\n' "$pid" > "$home/state/.lock" + printf 'format=1\nkind=ancestry\npid=%s\nsession=%s\n' "$pid" "$session" \ + > "$home/state/.lock.session" +} + wait_for_startup_network_wake() { # [tenths] local home=$1 limit=${2:-50} waited=0 while ! grep -Fq $'check\tstartup-network' "$home/state/.wake-queue" 2>/dev/null \ @@ -141,7 +155,7 @@ test_start_returns_without_holding_the_callers_stdout() { IFS='|' read -r home root log < "$home/state/.lock" + write_lock_binding "$home" "$$" started=$(date +%s) # Command substitution reads to EOF, exactly like a hook harvesting hook output. @@ -363,7 +377,7 @@ EOF # A detached start captures the lock itself and may run the mutating phase. : > "$log" - printf '%s\n' $$ > "$home/state/.lock" + write_lock_binding "$home" "$$" FM_FAKE_BOOTSTRAP_LOG="$log" run_stage "$home" "$root" start --locked 1 --harvest-pid $$ run_stage "$home" "$root" wait 30 >/dev/null || fail "the lock-authorized worker never published" assert_grep 'network=only detect_only=0' "$log" \ @@ -371,6 +385,27 @@ EOF pass "fm-startup-network: manual callers cannot forge mutation authority" } +test_worker_refuses_a_reused_pid_with_a_new_lock_binding() { + local rec home root log + rec=$(new_world binding-changed) + IFS='|' read -r home root log < "$home/state/.lock" + write_lock_binding "$home" "$$" FM_FAKE_BOOTSTRAP_LOG="$log" FM_FAKE_BOOTSTRAP_SLEEP=20 FM_STARTUP_NETWORK_TIMEOUT=2 \ run_stage "$home" "$root" start --locked 1 --harvest-pid $$ @@ -442,7 +477,7 @@ test_start_is_single_flight() { IFS='|' read -r home root log < "$home/state/.lock" + write_lock_binding "$home" "$$" FM_FAKE_BOOTSTRAP_LOG="$log" FM_FAKE_BOOTSTRAP_SLEEP=6 \ run_stage "$home" "$root" start --locked 1 --harvest-pid $$ @@ -500,7 +535,7 @@ EOF next_owner=$(/bin/ps -o ppid= -p $$ | tr -d ' ') printf '%s\n' "$next_owner" > "$home/state/.lock" FM_FAKE_HARNESS_PID_OVERRIDE="$next_owner" FM_FAKE_BOOTSTRAP_LOG="$log" FM_FAKE_BOOTSTRAP_SLEEP=1 \ - run_stage "$home" "$root" start --locked 1 --harvest-pid $$ + run_stage "$home" "$root" start --locked 0 --harvest-pid $$ generation_two=$(sed -n 's/^generation=//p' "$home/state/.startup-network.status") [ "$generation_one" != "$generation_two" ] \ || fail "the new lock owner reused the previous owner's generation" @@ -514,7 +549,7 @@ test_lock_takeover_stays_read_only_while_a_sweep_holds_the_lease() { IFS='|' read -r home root log < "$home/state/.lock" + write_lock_binding "$home" "$$" FM_FAKE_BOOTSTRAP_LOG="$log" FM_FAKE_BOOTSTRAP_SLEEP=6 \ run_stage "$home" "$root" start --locked 1 --harvest-pid $$ while [ ! -s "$log" ] && [ "$waited" -lt 50 ]; do @@ -699,6 +734,7 @@ test_a_report_publication_failure_is_failed_and_still_wakes test_a_successful_result_never_queues_a_wake test_an_actionable_successful_result_still_queues_a_wake test_mutating_sweeps_are_refused_when_the_lock_changed_hands +test_worker_refuses_a_reused_pid_with_a_new_lock_binding test_the_stage_bound_is_reported_not_swallowed test_an_abandoned_run_reads_as_needing_a_rerun test_start_is_single_flight diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 5c21f4d4306..c31c61ce0dc 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1133,8 +1133,15 @@ run_integrated_autoarm() { local dir=$1 home home=$(cd "$dir" && pwd) # shellcheck disable=SC2016 # the fake harness expands FM_HOME inside its child shell. + # The fleet lock binds a Claude Stop-hook run to its session identity, and the + # fake harness ("fake-claude") is detected as Claude ancestry. Supply the + # controlled identity inside the child so the lock-owning session is + # established from CLAUDECODE + a stable session id + this live pid (the same + # pid written to .lock) instead of any ambient identity leaked from the suite + # runner. Without it the auto-arm fails closed on identity and exits 0. printf '{"session_id":"sess-claude-mode","stop_hook_active":false}\n' \ | FM_HOME="$home" "$dir/fake-claude" -c ' + export CLAUDECODE=1 CLAUDE_CODE_SESSION_ID="integrated-autoarm-session-$$" CLAUDE_PID=$$ printf "%s\n" "$$" > "$FM_HOME/state/.lock" "$FM_HOME/bin/fm-claude-stop-autoarm.sh" ' 2>&1