Skip to content
Open
28 changes: 22 additions & 6 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/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 the verified harness (agent) process PID that identifies the session.
# This ordinarily comes from the shell's ancestry; a Claude call served through
# a reparented worker pool may instead retain its already-recorded published
# session PID. Either PID lives as long as the firstmate session, unlike the
# transient subshell PID of any one tool call.
# Usage: fm-lock.sh acquire; exit 1 unless ownership is verified
# fm-lock.sh status print holder and liveness; always exits 0
set -u
Expand All @@ -17,9 +19,9 @@ mkdir -p "$STATE" 2>/dev/null || {
exit 1
}

# Harness identity (FM_HARNESS_RE, ancestry walk, holder liveness) is owned by
# the shared session-lock lib so the Claude Stop auto-arm applies the exact
# same identity contract.
# Harness identity (FM_HARNESS_RE, ancestry or published session identity, and
# holder liveness) is owned by the shared session-lock lib so the Claude Stop
# auto-arm applies the exact same identity contract.
# shellcheck source=bin/fm-session-lock-lib.sh
. "$SCRIPT_DIR/fm-session-lock-lib.sh"

Expand All @@ -34,6 +36,19 @@ if [ "${1:-}" = "status" ]; then
fi

me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; }
# A call served by a reparented worker pool is rooted at pid 1, so the session
# that acquired this lock is not in the ancestry $me came from and every check
# below would read this session's own lock as a competing session's. When the
# harness names its session itself and the lock already records exactly that pid,
# this IS the owning session: adopt the recorded pid so those checks compare like
# with like. Ownership is only ever recognized here, never transferred - a lock
# this session does not already hold leaves $me as the ancestry resolved it.
if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then
session_pid=$(fm_harness_session_pid "$LOCK") || session_pid=''
if [ -n "$session_pid" ] && [ "$session_pid" = "$(cat "$LOCK" 2>/dev/null || true)" ]; then
me=$session_pid
fi
fi
probe=$(mktemp "$STATE/.lock-write.XXXXXX" 2>/dev/null) || {
echo "error: cannot write session lock; operate read-only until resolved" >&2
exit 1
Expand Down Expand Up @@ -91,6 +106,7 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then
exit 1
fi
fi
fm_session_lock_wait_until_publishable "$me"
if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then
echo "error: cannot write session lock; operate read-only until resolved" >&2
exit 1
Expand Down
124 changes: 114 additions & 10 deletions bin/fm-session-lock-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# 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 does the current session own that lock?" 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.
Expand Down Expand Up @@ -152,20 +152,124 @@ fm_harness_pid_alive() {
fm_harness_process_matches "$comm" "$args"
}

# True when state dir $1 holds a session lock whose pid is ANY harness ancestor
# of the current process: this script runs inside the session that owns the
# home's fleet lock. Membership is the honest test of that question, because the
# lock owner sits at an unknown depth in a contiguous Claude run - it is the
# outermost pid when the hook fires inside the session's own nested worker chain,
# and an inner pid when a harness-named daemon parents the session. A missing
# lock, a malformed lock, a lock held by a harness outside this ancestry, or an
# ancestry that cannot be resolved all fail closed.
# Print the pid of the session the harness itself publishes for lock path $1,
# or return 1.
#
# Ancestry answers "which harness am I running inside" only while the caller is
# actually a descendant of its session. Claude Code serves tool and hook
# commands from a per-user worker pool (claude daemon run -> bg-pty-host ->
# bg-spare) that is reparented to init, so the ancestry of such a call
# terminates at pid 1 inside the pool and never reaches the interactive session
# that acquired this home's lock. CLAUDE_PID is exported into every one of those
# commands and names that session directly, which is why it survives the gap
# that ancestry cannot cross. Claude Code is the only verified harness that
# publishes one today; every other harness has no such variable and keeps the
# ancestry-only behavior below unchanged.
#
# The pid is trusted only while it is still a live Claude Code process that
# strictly predates the lock. The lock's existing mtime is process-generation
# evidence: if an exited session's pid is recycled, the replacement process
# starts at or after the lock the original session published and is rejected
# even when the replacement is another Claude process.
#
# Trust boundary. The variable is inherited by any child, so on its own it says
# "a Claude session named this pid", never "I am that session". That is why
# callers must use it strictly to WIDEN ownership and never to replace the
# ancestry test: the only conclusion drawn from it here is that a lock ALREADY
# recording this exact pid belongs to a live session rather than a competing
# one, which is true however deep the caller sits below that session. It can
# therefore never let a caller take a lock away from another session, and never
# turns an unheld lock into a held one.
fm_harness_session_pid() { # <lock-path>
local lock=$1 pid=${CLAUDE_PID:-} started started_epoch lock_epoch
case "$pid" in
''|*[!0-9]*) return 1 ;;
esac
[ -f "$lock" ] && [ ! -L "$lock" ] || return 1
fm_harness_pid_alive "$pid" || return 1
[ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || return 1
started=$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null) || return 1
started=$(printf '%s' "$started" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -n "$started" ] || return 1
started_epoch=$(LC_ALL=C date -d "$started" +%s 2>/dev/null) \
|| started_epoch=$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$started" +%s 2>/dev/null) \
|| return 1
lock_epoch=$(stat -f %m "$lock" 2>/dev/null) \
|| lock_epoch=$(stat -c %Y "$lock" 2>/dev/null) \
|| return 1
case "$started_epoch:$lock_epoch" in
*[!0-9:]*|:*|*:) return 1 ;;
esac
# A session already running when this change landed cannot prove ownership if
# its existing lock was published during its process-start second. That is the
# same behavior the session already had, not a regression: the old writer
# recorded no generation evidence that could distinguish the original process
# from a pid recycled within that second, so this path declines to widen rather
# than inventing evidence. The gap lasts at most that session's lifetime and
# self-heals when the next session publishes after the bounded wait below.
[ "$started_epoch" -lt "$lock_epoch" ] || return 1
printf '%s\n' "$pid"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

# Wait until a new lock for harness pid $1 can carry unambiguous whole-second
# generation evidence when the verified Claude signals are available. Claude's
# published session pid is the only identity that uses lock mtime, so every
# other harness and every unverified environment return immediately. ps exposes
# process start only to whole-second precision on both supported platforms;
# publishing during that same second would make a recycled pid indistinguishable
# from the original process. The bounded wait moves the one initial lock
# publication past that boundary so the strict comparison above can reject
# equality without making a normal just-started session read-only.
fm_session_lock_wait_until_publishable() { # <harness-pid>
local pid=$1 started started_epoch now i=0
[ "${CLAUDE_PID:-}" = "$pid" ] || return 0
fm_harness_pid_alive "$pid" || return 0
[ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || return 0
started=$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null) || return 0
started=$(printf '%s' "$started" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -n "$started" ] || return 0
started_epoch=$(LC_ALL=C date -d "$started" +%s 2>/dev/null) \
|| started_epoch=$(LC_ALL=C date -j -f '%a %b %e %T %Y' "$started" +%s 2>/dev/null) \
|| return 0
case "$started_epoch" in
''|*[!0-9]*) return 0 ;;
esac
while [ "$i" -lt 40 ]; do
now=$(date +%s 2>/dev/null) || return 0
case "$now" in
''|*[!0-9]*) return 0 ;;
esac
[ "$now" -gt "$started_epoch" ] && return 0
sleep 0.05
i=$((i + 1))
done
return 0
}

# True when state dir $1 holds a session lock owned by the current session.
# Ancestry membership is the ordinary test of that question, because the lock
# owner sits at an unknown depth in a contiguous Claude run - it is the outermost
# pid when the hook fires inside the session's own nested worker chain, and an
# inner pid when a harness-named daemon parents the session. A missing lock, a
# malformed lock, a lock held by a harness outside this ancestry, or an ancestry
# that cannot be resolved all fail closed unless the published-session check
# below establishes the worker-pool case.
#
# Membership proves ownership when it holds, but its absence proves nothing: a
# call served by a reparented worker pool has no ancestry path to its own
# session at all, so a session that genuinely holds this lock would be refused
# its own home and forced read-only. The published session pid answers exactly
# that case and is checked first. It only ever widens acceptance - a lock this
# session does not already hold is still decided by the ancestry walk below.
fm_session_lock_owned_by_self() {
local state=$1 lock_pid pids pid
local state=$1 lock_pid pids pid session_pid
lock_pid=$(cat "$state/.lock" 2>/dev/null || true)
case "$lock_pid" in
''|*[!0-9]*) return 1 ;;
esac
if session_pid=$(fm_harness_session_pid "$state/.lock") && [ "$session_pid" = "$lock_pid" ]; then
return 0
fi
pids=$(fm_harness_ancestry_pids) || return 1
while IFS= read -r pid; do
[ "$pid" = "$lock_pid" ] && return 0
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,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 harness identity (ancestry, Claude's published session pid, 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 |
Expand Down
17 changes: 12 additions & 5 deletions docs/verification/supervision.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,14 +289,21 @@ 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 ordinarily decided against a session's whole contiguous harness ancestry, 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.
Ancestry cannot answer it when Claude Code serves the call from a per-user worker pool reparented to init, because the chain terminates at pid 1 without reaching the interactive session that acquired the lock.
The session pid Claude Code publishes as `CLAUDE_PID` widens acceptance for exactly that gap: a lock the session does not already record as its own is still decided by the ancestry walk, and the published pid is trusted only while it identifies a live Claude harness whose process start strictly predates the lock publication.
Because both supported `ps` implementations expose the process start at whole-second precision, initial Claude lock publication waits for the next whole-second boundary with a bounded retry count.
The strict lock-mtime generation check then rejects an old inherited value when its numeric pid is recycled onto another Claude session, including a replacement that starts during the original lock-publication second.
A session already running when this change lands cannot widen ownership for a pre-existing lock published during its process-start second because that writer recorded no evidence that distinguishes the original process from a pid recycled within the same second.
That unchanged limitation lasts at most the existing session's lifetime and self-heals when the next session publishes after the bounded wait.
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 behind a deterministic process table, covers the reparented worker-pool gap and competing-owner boundary, 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.

The Claude product live path ran with Claude Code 2.1.219 on 2026-07-24:
The Claude product live path ran with Claude Code 2.1.241 on 2026-08-23.
Every real Bash `PreToolUse` hook received the same numeric `CLAUDE_PID`, `fm_harness_session_pid` verified it as a live Claude process while the hook ran, and session start recorded that exact pid as the lock owner across the daemon-served tool path.

```sh
claude --version
Expand All @@ -306,8 +313,8 @@ FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh
Observed output:

```text
2.1.219 (Claude Code)
ok - Claude 2.1.219 (Claude Code) live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary
2.1.241 (Claude Code)
ok - Claude 2.1.241 (Claude Code) live E2E propagated one verified CLAUDE_PID through every Bash hook, recorded it as the session lock, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary
```

Current entry points:
Expand Down
18 changes: 16 additions & 2 deletions tests/fm-claude-stop-autoarm-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
# session lock can run fm-session-start.sh first; session start reclaims the
# dead owner; at least two tokenless auto-arm and rewake cycles then complete
# with zero model-issued arm commands; and the cooperative guard consumes no
# forced continuation while the hook's launch is healthy.
# forced continuation while the hook's launch is healthy; and every real Bash
# hook receives a live Claude session pid that becomes the acquired lock owner.
# The project and FM_HOME are isolated; Claude keeps using its existing managed
# authentication. No live fleet home, worktree, or session is touched.
# shellcheck disable=SC2016 # the model, not this test shell, reads the prompt text
Expand Down Expand Up @@ -67,6 +68,8 @@ cat > "$PROJECT/bin/tool-logger.sh" <<'SH'
#!/usr/bin/env bash
P=$(cat 2>/dev/null || true)
printf '%s\n' "$P" | jq -r '.tool_input.command // "unknown"' >> "$FM_HOME/state/tool-calls.log" 2>/dev/null
VALIDATED_PID=$(bash -c '. "$1"; fm_harness_session_pid "$2"' _ "$CLAUDE_PROJECT_DIR/bin/fm-session-lock-lib.sh" "$FM_HOME/state/.lock" 2>/dev/null || true)
printf '%s\t%s\n' "${CLAUDE_PID:-}" "$VALIDATED_PID" >> "$FM_HOME/state/claude-session-pids.log"
exit 0
SH
chmod +x "$PROJECT/bin/tool-logger.sh"
Expand Down Expand Up @@ -127,6 +130,17 @@ grep -q 'stale: fixture-rapid-2' "$TRANSCRIPT" || fail "second rapid rewake reas
|| fail "fresh Claude session did not run session start first: $(cat "$HOME_DIR/state/tool-calls.log" 2>/dev/null)"
[ "$(cat "$HOME_DIR/state/.lock" 2>/dev/null)" != 9999999 ] \
|| fail "session start did not reclaim the stale dead-owner lock"
PUBLISHED_PID=$(awk -F '\t' 'NR == 1 { print $1 }' "$HOME_DIR/state/claude-session-pids.log" 2>/dev/null)
case "$PUBLISHED_PID" in
''|*[!0-9]*) fail "Claude $CLAUDE_VERSION did not export a numeric CLAUDE_PID to its Bash hook" ;;
esac
awk -F '\t' -v pid="$PUBLISHED_PID" '
NF != 2 || $1 != pid || $2 != pid { inconsistent = 1 }
END { exit inconsistent }
' "$HOME_DIR/state/claude-session-pids.log" \
|| fail "Claude $CLAUDE_VERSION did not export one live Claude CLAUDE_PID consistently to every Bash hook: $(cat "$HOME_DIR/state/claude-session-pids.log")"
[ "$(cat "$HOME_DIR/state/.lock" 2>/dev/null)" = "$PUBLISHED_PID" ] \
|| fail "session start recorded $(cat "$HOME_DIR/state/.lock" 2>/dev/null), not hook-published CLAUDE_PID $PUBLISHED_PID"
if [ -f "$HOME_DIR/state/tool-calls.log" ]; then
! grep -q 'fm-watch-arm.sh' "$HOME_DIR/state/tool-calls.log" \
|| fail "model issued an arm command despite Stop-owned continuity: $(cat "$HOME_DIR/state/tool-calls.log")"
Expand Down Expand Up @@ -161,4 +175,4 @@ printf '%s\n' '{"session_id":"live-owner-control"}' \
[ ! -s "$LAB/live-owner.out" ] && [ ! -s "$LAB/live-owner.err" ] || fail "competing Stop hook produced a rewake while another live session owned the home"
wait "$LIVE_OWNER_PID"

printf 'ok - Claude %s live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary\n' "$CLAUDE_VERSION"
printf 'ok - Claude %s live E2E propagated one verified CLAUDE_PID through every Bash hook, recorded it as the session lock, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary\n' "$CLAUDE_VERSION"
Loading
Loading