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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand All @@ -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 ------------------------------------------------
Expand Down
25 changes: 17 additions & 8 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
256 changes: 247 additions & 9 deletions bin/fm-session-lock-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ fm_harness_path_name() { # <path>
# 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() { # <comm> <args>
local comm=$1 args=$2 base argv0 name
FM_HARNESS_IS_CLAUDE=0
Expand Down Expand Up @@ -116,14 +120,23 @@ fm_harness_process_matches() { # <comm> <args>
# 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
Expand All @@ -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
Expand All @@ -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 <<EOF
$pids
$FM_HARNESS_ANCESTRY_PIDS
EOF
[ -n "$outermost" ] || return 1
printf '%s\n' "$outermost"
Expand All @@ -171,16 +190,235 @@ fm_harness_pid_alive() {
# lock, a malformed lock, a lock held by a harness outside this ancestry, or an
# ancestry that cannot be resolved all fail closed.
fm_session_lock_owned_by_self() {
local state=$1 lock_pid pids pid
local state=$1 lock_pid pid
lock_pid=$(cat "$state/.lock" 2>/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 <<EOF
$pids
$FM_HARNESS_ANCESTRY_PIDS
EOF
return 1
}

# True when this run's verified harness is Claude.
#
# A Claude worker pool can be reparented away from the session it serves, so
# its ancestry is not by itself a session identity. Every other currently
# verified harness has one contiguous harness pid and retains the established
# ancestry contract above.
fm_harness_ancestry_is_claude() {
fm_harness_ancestry_cache || return 1
[ "$FM_HARNESS_ANCESTRY_IS_CLAUDE" -eq 1 ]
}

# Set the identity a NEW session lock must record.
#
# Claude Code supplies both a stable session id and the served session pid.
# Require both, and require that pid to be a live verified harness, before a
# new Claude lock may be written. This deliberately does not guess from a
# reparented worker-pool ancestry. Other supported harnesses expose their
# session through their one verified ancestry pid, which remains their stable
# lock identity.
FM_SESSION_LOCK_OWNER_KIND=
FM_SESSION_LOCK_OWNER_PID=
FM_SESSION_LOCK_OWNER_SESSION=
fm_session_lock_prepare_acquisition_identity() {
local ancestry_pid session_id session_pid
FM_SESSION_LOCK_OWNER_KIND=
FM_SESSION_LOCK_OWNER_PID=
FM_SESSION_LOCK_OWNER_SESSION=
ancestry_pid=$(fm_harness_ancestry_pid) || return 1
if fm_harness_ancestry_is_claude; then
[ "${CLAUDECODE:-}" = 1 ] || return 1
session_id=${CLAUDE_CODE_SESSION_ID:-}
session_pid=${CLAUDE_PID:-}
case "$session_id" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac
case "$session_pid" in ''|*[!0-9]*) return 1 ;; esac
fm_harness_pid_alive "$session_pid" || return 1
FM_SESSION_LOCK_OWNER_KIND=claude
FM_SESSION_LOCK_OWNER_PID=$session_pid
FM_SESSION_LOCK_OWNER_SESSION=$session_id
return 0
fi
FM_SESSION_LOCK_OWNER_KIND=ancestry
FM_SESSION_LOCK_OWNER_PID=$ancestry_pid
FM_SESSION_LOCK_OWNER_SESSION=$ancestry_pid
}

# Print the adjacent new-format binding path for state dir $1.
# state/.lock remains a bare pid for its existing readers.
fm_session_lock_record_path() { # <state-dir>
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() { # <record-path>
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() { # <state-dir>
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() { # <state-dir>
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() { # <state-dir> <record-path>
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() { # <state-dir> <kind> <pid> <session>
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() { # <kind> <pid> <session>
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() { # <state-dir>
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() { # <state-dir> <pid>
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() { # <state-dir>
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() { # <state-dir>
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"
}
Loading
Loading