Skip to content
Closed
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ state/ runtime records and signals; gitignored
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
.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
.claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock .turnend-readonly-advised Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, budget-lock, and read-only-notice 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
.hash-* .count-* .stale-* .stale-since-* .churn-since-* .paused-* .wedge-escalations-* .writing-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete
Expand Down
27 changes: 26 additions & 1 deletion bin/fm-session-lock-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
# lock, and does the current process descend from that same harness?" 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.
# lock-owning primary session before it may arm or rewake; bin/fm-turnend-guard.sh
# uses it to recognize a read-only session whose fleet lock another live session
# holds.
# This file is sourced by scripts and has no side effects on source.

# Cursor process identity is NOT expressible as a command-name pattern and is
Expand Down Expand Up @@ -181,3 +183,26 @@ $pids
EOF
return 1
}

# Print the lock pid and return 0 only with positive proof that a DIFFERENT live
# session owns state dir $1's fleet lock: this process's harness ancestry
# resolved, the lock pid is none of those ancestors, and it is a live verified
# harness. That is the read-only session: it may not arm, drain, or repair
# supervision, so recovery belongs to the holder. A missing or malformed lock, a
# dead or non-harness holder, and an unresolvable ancestry all return 1, because
# none of them proves someone else is responsible.
fm_session_lock_held_by_other_harness() {
local state=$1 lock_pid pids 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
while IFS= read -r pid; do
[ "$pid" = "$lock_pid" ] && return 1
done <<EOF
$pids
EOF
fm_harness_pid_alive "$lock_pid" || return 1
printf '%s\n' "$lock_pid"
}
31 changes: 31 additions & 0 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@
# re-block (budget_account_current_epoch owns that rule), so an inert
# hook that leaves the ledger frozen cannot hold the guard in an
# unbounded re-block loop below that override.
#
# Read-only sessions: a session whose fleet lock another live session holds may
# not repair supervision, so every mode lets its stop through instead of
# blocking (fm_session_lock_held_by_other_harness in bin/fm-session-lock-lib.sh
# owns that proof). The lock owner itself is guarded exactly as described above.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down Expand Up @@ -167,6 +172,8 @@ fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0
# --- the actual predicate ----------------------------------------------------
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-session-lock-lib.sh
. "$SCRIPT_DIR/fm-session-lock-lib.sh"

BUDGET_FILE="$STATE/.turnend-claude-blocks"
BUDGET_LOCK="$STATE/.turnend-claude-blocks.lock"
Expand Down Expand Up @@ -216,6 +223,30 @@ if [ "$(fm_path_age "$STATE/.last-watcher-beat")" -lt "$AFK_GRACE" ] \
allow_supervised_stop
fi

# --- a read-only session: another live session owns this home's fleet lock ---
# A session that did not get the lock may not arm, drain, or repair supervision,
# and its own Stop auto-arm stays inert by identity, so a block here can never
# bring supervision back - it only traps that session in an endless re-block.
# Recovery belongs to the lock holder, whose Stop boundary this guard still
# blocks. Claude mode tells the user once per session and holder; every other
# mode allows silently. Only positive proof stands down: a missing, malformed,
# or dead lock and an unresolvable ancestry fall through to the ordinary block.
READONLY_NOTICE="$STATE/.turnend-readonly-advised"
if LOCK_HOLDER=$(fm_session_lock_held_by_other_harness "$STATE"); then
if [ "$CLAUDE_MODE" -eq 1 ]; then
NOTICE_KEY="session=$SESSION_ID holder=$LOCK_HOLDER"
if ! grep -Fxq "$NOTICE_KEY" "$READONLY_NOTICE" 2>/dev/null; then
NOTICE_TMP="$READONLY_NOTICE.tmp.$$"
if { tail -n 49 "$READONLY_NOTICE" 2>/dev/null; printf '%s\n' "$NOTICE_KEY"; } > "$NOTICE_TMP" 2>/dev/null; then
mv -f "$NOTICE_TMP" "$READONLY_NOTICE" 2>/dev/null || true
fi
rm -f "$NOTICE_TMP" 2>/dev/null || true
Comment on lines +239 to +243

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the read-only path free of state writes

When this branch is reached, the caller has positively been identified as a lock-refused session, yet it creates, replaces, and removes a file under the shared state directory. This violates the read-only-session boundary and lets a non-owner race home-state changes with the actual lock holder; emit the notice without persisting from the refused session, or arrange persistence through the lock owner.

AGENTS.md reference: AGENTS.md:L173-L174

Useful? React with πŸ‘Β / πŸ‘Ž.

printf '{"systemMessage":"Firstmate monitoring is off, and this session cannot restart it: another open session (pid %s) owns this fleet. Message or close that session to restart monitoring. This session will not be blocked."}\n' "$LOCK_HOLDER"
fi
fi
exit 0
fi

block_stop() {
local afk x_mode reason rule
afk=0
Expand Down
9 changes: 7 additions & 2 deletions docs/turnend-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,13 @@ When none of those proofs appears, it re-blocks up to `FM_CLAUDE_TURNEND_BLOCK_B
In Claude mode, positive watcher recovery clears the block budget, failure notice, and attended alarm together under the existing budget lock before either hook reports ordinary recovery.
The one loud attended fail-open is available only when the auto-arm has recorded an exhausted failure, its one notice is already consumed, the block budget is exhausted, and a final check finds neither a healthy watcher nor an automatic continuation.
Each epoch identity is charged at most once per Stop under the budget lock, and a re-block against an epoch the auto-arm did not advance past the previous re-block is charged as well.
That second rule is what bounds an inert auto-arm: a hook kept silent by a session lock held by a live harness outside its ancestry, a hook that never fires, or a hook failing before its generation claim leaves the ledger frozen at its last outcome.
That second rule is what bounds an inert auto-arm: a hook that never fires or a hook failing before its generation claim leaves the ledger frozen at its last outcome.
Charging only epoch changes let the count freeze with that ledger, so the guard re-blocked without limit and the attended fail-open was never reachable; `budget_account_current_epoch` in `bin/fm-turnend-guard.sh` owns the rule.
A read-only session, whose fleet lock a different live harness holds, is not blocked in any mode.
Its own auto-arm is inert by identity and it may not repair supervision, so a block could never cause recovery and, lacking a verified failure episode, would re-block forever.
`fm_session_lock_held_by_other_harness` in `bin/fm-session-lock-lib.sh` owns that proof, and a missing, malformed, or dead lock or an unresolvable ancestry keeps the ordinary block.
Claude mode names the lock holder in one `systemMessage` per session and holder, and spends none of the block budget; the lock owner's own Stop boundary stays guarded as above.
`state/.turnend-readonly-advised` records each advised pair as one `session=<id> holder=<pid>` line, keeping only the last 50 lines, so several read-only sessions sharing a home each see the notice once.
Whenever both coordination locks are needed, positive auto-arm recovery and the terminal check acquire the auto-arm owner lock before the budget lock.
After that alarm, the Stop auto-arm suppresses further exit-2 continuations until positive watcher recovery, so the final fail-open remains reachable.
The alarm cannot repeat during that failure episode, and a later unhealthy stop blocks again.
Expand Down Expand Up @@ -186,7 +191,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa

## Regression coverage

`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the live-lock and fresh-beacon guard predicate, the cooperative `--claude` open-generation claim wait, monotonic failed-epoch progression, bounded attended fail-open, the same bound against a ledger frozen by an inert auto-arm with and without a verified failure episode, post-alarm continuation suppression, positive recovery reset, generation and legacy claim cases that must block or clear instead of allowing a blind stop, away-mode daemon ownership between watcher cycles and over a watcher lock left behind by an exited watcher, plus its dead, pid-reused, absent, stale-beacon, and away-mode-off negatives, the away-mode beacon's poll-derived grace widening for a live daemon still mid-cycle and its bound against a dead daemon, a beacon older than that wider grace, and FM_POLL's inapplicability with away mode off, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety.
`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the live-lock and fresh-beacon guard predicate, the cooperative `--claude` open-generation claim wait, monotonic failed-epoch progression, bounded attended fail-open, the same bound against a ledger frozen by a silent auto-arm with and without a verified failure episode, the read-only stand-down while another live session holds the fleet lock with its once-per-holder notice and the lock-owner and dead-holder blocks, post-alarm continuation suppression, positive recovery reset, generation and legacy claim cases that must block or clear instead of allowing a blind stop, away-mode daemon ownership between watcher cycles and over a watcher lock left behind by an exited watcher, plus its dead, pid-reused, absent, stale-beacon, and away-mode-off negatives, the away-mode beacon's poll-derived grace widening for a live daemon still mid-cycle and its bound against a dead daemon, a beacon older than that wider grace, and FM_POLL's inapplicability with away mode off, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety.
`tests/fm-guard-stale-banner.test.sh` covers the pull-guard predicate, including the persistent-model fresh-leftover-beacon negative control; the auto-arm model's healthy fresh-beacon-without-a-watcher case, session-and-recovery-bound long-turn rewake tolerance, independently broken tolerance signals, open-claim negative control, stale-beacon alarm, and isolation from other models; and the extension model's live-watcher path, ownership-qualified fresh hand-off, held-lock failures, independently broken ownership signals, stale-beacon alarm, queued-wake warning, and Pi and pi-signed harness routing.
It also covers true-reason banner wording and reason-keyed episode dedup surviving a beacon mtime change.
`tests/fm-cursor-primary.test.sh` covers the Cursor park end to end over real processes with no harness installed: each tracked Claude-shaped entrypoint standing down on a Cursor payload, both follow-up sources, the bounded repair nag and its reset, the nested loop bounds, supersession, away-mode and lock-ownership inertness, Pi-host stand-down without Cursor identity and continued parking when `PI_CODING_AGENT` leaks alongside `CURSOR_AGENT` or `CURSOR_INVOKED_AS`, child-worktree exclusion, and that the adapter never exits 2.
Expand Down
Loading