Skip to content
Merged
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ state/ runtime records and signals; gitignored
tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll
mail.check.sh generated received-mail poll shim and its .check-trust binding; present only after bin/fm-mail-check.sh arm; report record .mail-check (mail schema: docs/configuration.md "Mail plane")
.mail-seen .mail-woken .mail-retry .mail-retry-pos .mail-turn .mail-seen.lock mail-plane poll cursor, emission journal, transient-fetch retry set, retry-scan position, contended-slot turn flag, and overlapping-poll lock; written only by bin/fm-mail.sh (mail schema: docs/configuration.md "Mail plane")
telegram/ accepted-message records keyed by update_id; written only by bin/fm-telegram.sh
.telegram-offset last processed update_id; written only by bin/fm-telegram.sh
.telegram-woken durable emission journal of accepted messages whose wake was published; written only by bin/fm-telegram.sh
.telegram-stats cumulative accepted and dropped message counts; written only by bin/fm-telegram.sh
.telegram-offset.lock overlapping-poll serialization lock; written only by bin/fm-telegram.sh (Telegram schema: docs/configuration.md "Telegram plane")
pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh
procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13)
procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line
Expand All @@ -146,6 +151,7 @@ state/ runtime records and signals; gitignored
.afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, spend cap, and structured mandate clauses; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh)
afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window
.afk durable away/quiet-mode daemon flag on the harnesses that still launch the daemon (never on Pi); present = sub-supervisor may inject escalations, first line `away` (default, set by /afk, cleared on user return) or `quiet` (set by /quiet, cleared only on explicit /quiet off) per the single owner fm_afk_mode() in bin/fm-wake-lib.sh
.lock-session trusted Claude session-lock sidecar; written only by bin/fm-lock.sh; never touch
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
.claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch
.cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch
Expand Down
6 changes: 5 additions & 1 deletion bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
# - Scope: only a genuine primary checkout (plain checkout or validly marked
# secondmate home) with AGENTS.md, bin/, and the effective state dir - the
# exact fm-turnend-guard.sh scope. Child crew/scout worktrees stay inert.
# - Identity: only when THIS session's harness ancestor holds state/.lock.
# - Identity: only when THIS session holds state/.lock, as
# bin/fm-session-lock-lib.sh decides it: the recorded pid is a harness
# ancestor, or a live lock was recorded under this same trusted Claude
# session id (which is what keeps a background session arming after its
# transient helper chain is recycled).
# When an existing numeric owner fails the shared harness-liveness predicate,
# the hook delegates guarded recovery to bin/fm-lock.sh and then re-verifies
# ownership. A live owner, missing lock, malformed lock, or unresolved
Expand Down
192 changes: 178 additions & 14 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
#!/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.
#
# Line 1 of state/.lock is the owning session's anchor pid, resolved by
# fm_session_lock_anchor_pid in bin/fm-session-lock-lib.sh: the harness (agent)
# process 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. For a Claude session that proves a
# trusted session id the anchor is CLAUDE_PID, the model-loop process, so a
# shared transient daemon or a front-end that outlives the session never keeps
# a dead session's lock alive. Line 1 keeps its whole-line pid format because
# every other reader takes the first line as the pid.
#
# The trusted id itself is recorded beside the lock in state/.lock-session, a
# sidecar written only here and only under the claim lock: refreshed on every
# confirmed-own acquisition, including the early already-mine exit that waits
# for the claim lock, removed when the acquiring session proves no trusted id,
# and left byte-identical when it already names that id. A same-session
# confirmation never rewrites line 1 while the recorded pid is alive, because
# bin/fm-startup-network.sh compares that pid across its deferred sweeps; a dead
# recorded pid is reclaimed and rewritten to this session's anchor.
#
# 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 @@ -12,14 +29,15 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
LOCK="$STATE/.lock"
LOCK_SESSION="$STATE/.lock-session"
mkdir -p "$STATE" 2>/dev/null || {
echo "error: cannot create session-lock state directory $STATE; operate read-only until resolved" >&2
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 walk, holder liveness, trusted
# session id, anchor pid) 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 @@ -33,7 +51,7 @@ if [ "${1:-}" = "status" ]; then
exit 0
fi

me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; }
me=$(fm_session_lock_anchor_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; }
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 @@ -46,24 +64,135 @@ rm -f "$probe" 2>/dev/null || {
. "$SCRIPT_DIR/fm-wake-lib.sh"
CLAIM_LOCK="$STATE/.lock.acquire"
CLAIM_LOCK_HELD=0
# PHASE 0: committed/none. 1: sidecar mutated, line 1 not written. 2: line 1 written, not verified.
# KIND 0: no backup. 1: restore $LOCK_SESSION_PREV. 2: sidecar was absent.
LOCK_SESSION_PHASE=0
LOCK_SESSION_KIND=0
LOCK_SESSION_PREV="$STATE/.lock-session.prev"
LOCK_LINE_PRE=
release_claim_lock() {
if [ "$CLAIM_LOCK_HELD" -eq 1 ]; then
fm_lock_release "$CLAIM_LOCK"
CLAIM_LOCK_HELD=0
fi
}
trap release_claim_lock EXIT
restore_uncommitted_lock_session() {
case "$LOCK_SESSION_PHASE" in
1)
case "$LOCK_SESSION_KIND" in
1) mv -f "$LOCK_SESSION_PREV" "$LOCK_SESSION" 2>/dev/null || true ;;
2) rm -f "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || true ;;
esac
;;
2) rm -f "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || true ;;
esac
LOCK_SESSION_PHASE=0
LOCK_SESSION_KIND=0
}
commit_lock_session() {
LOCK_SESSION_PHASE=0
LOCK_SESSION_KIND=0
rm -f "$LOCK_SESSION_PREV" 2>/dev/null || true
}
on_lock_exit() {
restore_uncommitted_lock_session
[ -n "$LOCK_LINE_PRE" ] && rm -f "$LOCK_LINE_PRE"
release_claim_lock
}
trap on_lock_exit EXIT
trap 'exit 1' HUP INT TERM

remember_lock_session() {
[ "$LOCK_SESSION_PHASE" -eq 0 ] || return 0
if [ -e "$LOCK_SESSION" ] || [ -L "$LOCK_SESSION" ]; then
rm -f "$LOCK_SESSION_PREV" 2>/dev/null || true
cp -P "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || return 1
LOCK_SESSION_KIND=1
else
LOCK_SESSION_KIND=2
fi
LOCK_SESSION_PHASE=1
}

# Record the trusted session id beside the lock, or remove a sidecar that no
# trusted id backs. Called only while the claim lock is held. A sidecar already
# naming this id is left untouched, so a same-session confirmation keeps it
# byte-identical.
publish_lock_session() {
local trusted recorded tmp
if trusted=$(fm_session_lock_trusted_session_id); then
if recorded=$(fm_session_lock_recorded_session_id "$STATE") && [ "$recorded" = "$trusted" ]; then
return 0
fi
remember_lock_session || return 1
tmp=$(mktemp "$STATE/.lock-session.XXXXXX" 2>/dev/null) || return 1
if ! { printf '%s\n' "$trusted" > "$tmp" && mv -f "$tmp" "$LOCK_SESSION"; } 2>/dev/null; then
rm -f "$tmp" 2>/dev/null
return 1
fi
return 0
fi
if [ -e "$LOCK_SESSION" ] || [ -L "$LOCK_SESSION" ]; then
remember_lock_session || return 1
rm -f "$LOCK_SESSION" 2>/dev/null || return 1
fi
return 0
}

publish_lock_session_or_die() {
publish_lock_session && return 0
echo "error: cannot record the session identity beside the lock; operate read-only until resolved" >&2
exit 1
}

# This session already holds the lock, recorded as pid $1. Line 1 stays exactly
# as recorded while that pid is alive; only the sidecar is refreshed, under the
# claim lock, so a /clear re-key inside the same process replaces the old id.
# A same-session confirmation waits for the claim lock so the sidecar refresh
# completes. After the wait, the lock is re-read and the sidecar is refreshed
# only when this session still owns it; otherwise the claim lock is released
# and the caller continues with the ordinary live-owner or reclaim path. The
# prior-session-sweep-is-finishing refusal is a takeover rule and does not
# apply here.
confirm_own_lock() { # <recorded-pid>
local recorded waited=0
if [ "$CLAIM_LOCK_HELD" -ne 1 ]; then
fm_lock_acquire_wait "$CLAIM_LOCK"
CLAIM_LOCK_HELD=1
waited=1
fi
recorded=$(cat "$LOCK" 2>/dev/null || true)
if [ "$recorded" = "$me" ] || fm_session_lock_owned_by_self "$STATE"; then
publish_lock_session_or_die
commit_lock_session
release_claim_lock
echo "lock acquired: harness pid $recorded"
exit 0
fi
if [ "$waited" -eq 1 ]; then
release_claim_lock
fi
return 1
}

refuse_live_owner() { # <recorded-pid>
local recorded
if recorded=$(fm_session_lock_recorded_session_id "$STATE"); then
echo "error: another live firstmate session holds the lock (pid $1, session $recorded); operate read-only until resolved" >&2
else
echo "error: another live firstmate session holds the lock (pid $1); operate read-only until resolved" >&2
fi
exit 1
}

if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then
old=$(cat "$LOCK" 2>/dev/null || true)
if [ "$old" = "$me" ]; then
echo "lock acquired: harness pid $me"
exit 0
if [ "$old" = "$me" ] || fm_session_lock_owned_by_self "$STATE"; then
confirm_own_lock "$old"
old=$(cat "$LOCK" 2>/dev/null || true)
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
refuse_live_owner "$old"
fi
fi

Expand All @@ -87,11 +216,45 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then
exit 1
}
if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then
echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2
fm_session_lock_owned_by_self "$STATE" && confirm_own_lock "$old"
old=$(cat "$LOCK" 2>/dev/null || true)
if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then
refuse_live_owner "$old"
fi
fi
fi
# The sidecar goes first: a fresh pid beside a previous session's id would let
# that session's resume own this lock. If the sidecar changes before line 1 is
# written, a failure restores the previous sidecar. If line 1 is written but
# not yet verified, a failure removes the sidecar and leaves the lock
# ancestry-only. After line 1 verifies as this session's anchor, a later
# signal leaves the published pair in place.
publish_lock_session_or_die
if [ -f "$LOCK" ]; then
LOCK_LINE_PRE=$(mktemp "$STATE/.lock.pre.XXXXXX") || {
echo "error: cannot write session lock; operate read-only until resolved" >&2
exit 1
}
if ! cp "$LOCK" "$LOCK_LINE_PRE" 2>/dev/null; then
echo "error: cannot write session lock; operate read-only until resolved" >&2
exit 1
fi
fi
LOCK_SESSION_PHASE=2
if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then
lock_unchanged=0
if [ -n "$LOCK_LINE_PRE" ] && cmp -s "$LOCK_LINE_PRE" "$LOCK"; then
lock_unchanged=1
elif [ -z "$LOCK_LINE_PRE" ] && [ ! -e "$LOCK" ] && [ ! -L "$LOCK" ]; then
lock_unchanged=1
fi
if [ "$lock_unchanged" -eq 1 ]; then
if [ "$LOCK_SESSION_KIND" -ne 0 ]; then
LOCK_SESSION_PHASE=1
else
LOCK_SESSION_PHASE=0
fi
fi
echo "error: cannot write session lock; operate read-only until resolved" >&2
exit 1
fi
Expand All @@ -103,5 +266,6 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then
echo "error: session lock ownership verification failed; operate read-only until resolved" >&2
exit 1
fi
commit_lock_session
release_claim_lock
echo "lock acquired: harness pid $me"
Loading
Loading