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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ data/
scratchpad/
.no-mistakes/
.lavish/
.serena/
.fm-secondmate-home
.fm-secondmate-parent
.DS_Store
Expand Down
14 changes: 14 additions & 0 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,20 @@ if fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then
exit 2
fi

# Away mode tears the watcher down and relaunches it on every actionable wake
# and after a bounded crash-loop backoff (bin/fm-supervise-daemon.sh), so
# state/.watch.lock is routinely and briefly unheld or stale between watcher
# generations even while the daemon and its beacon are healthy. The strict
# fm_watcher_healthy check above cannot tell that benign hand-off apart from a
# genuinely abandoned watcher, so a live identity-matched daemon with a fresh
# beacon is accepted here as an alternate liveness proof, mirroring the Pi
# extension model's own hand-off tolerance (fm_pi_extension_owns_supervision).
if fm_afk_daemon_owns_supervision "$STATE" "$GRACE"; then
[ "$CLAUDE_MODE" -eq 1 ] || exit 0
fm_failure_episode_reset "$STATE" && exit 0
exit 2
fi

block_stop() {
local afk x_mode reason rule
afk=0
Expand Down
52 changes: 52 additions & 0 deletions bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,58 @@ fm_pi_extension_owns_supervision() {
fm_pid_alive "$session_pid"
}

# fm_afk_daemon_lock_owner_dir <state>
# Resolve state/.supervise-daemon.lock to its owner directory, whether the lock
# is the symlink-based fm_lock_* form or a plain directory (older layout).
fm_afk_daemon_lock_owner_dir() {
local state=$1 lockdir="$1/.supervise-daemon.lock"
if [ -L "$lockdir" ]; then
fm_lock_link_owner "$lockdir"
return
fi
[ -d "$lockdir" ] || return 1
printf '%s\n' "$lockdir"
}

# fm_afk_daemon_alive <state>
# True when a live, identity-matched away-mode sub-supervisor daemon
# (bin/fm-supervise-daemon.sh) currently holds state/.supervise-daemon.lock.
# Identity-matched mirrors fm_watcher_lock_matches_pid: a bare pid match would
# let a reused pid impersonate the daemon, so an unrecorded identity fails
# closed rather than guessing.
fm_afk_daemon_alive() {
local state=$1 ownerdir pid identity current
ownerdir=$(fm_afk_daemon_lock_owner_dir "$state") || return 1
pid=$(cat "$ownerdir/pid" 2>/dev/null) || return 1
fm_pid_alive "$pid" || return 1
identity=$(cat "$ownerdir/pid-identity" 2>/dev/null || true)
[ -n "$identity" ] || return 1
current=$(fm_pid_identity "$pid") || return 1
[ "$current" = "$identity" ]
}

# fm_afk_daemon_owns_supervision <state> [grace-seconds]
# True when away mode is active, a live identity-matched sub-supervisor daemon
# holds its own lock, and the watcher beacon it manages is fresh within grace.
# This is the daemon-managed analogue of fm_pi_extension_owns_supervision above:
# the daemon tears its watcher down and relaunches it after every actionable
# wake and after a bounded crash-loop backoff (bin/fm-supervise-daemon.sh's
# start_watcher), so state/.watch.lock is routinely and briefly either genuinely
# unheld or still naming the just-exited pid during that hand-off, even while
# the daemon itself and its beacon are perfectly healthy. fm_watcher_healthy
# alone cannot tell that benign gap apart from a watcher that quietly died and
# left a stale leftover beacon - a live daemon proves the gap is being actively
# closed, exactly like a live Pi session proves its own extension hand-off.
fm_afk_daemon_owns_supervision() {
local state=$1 grace=${2:-${FM_GUARD_GRACE:-300}} beat age
[ -e "$state/.afk" ] || return 1
fm_afk_daemon_alive "$state" || return 1
beat="$state/.last-watcher-beat"
[ -e "$beat" ] || return 1
age=$(fm_path_age "$beat")
[ "$age" -lt "$grace" ]
}

# fm_watcher_supervision_verdict <state> <watch-path> [grace] [home] [root]
# Model-aware "is supervision healthy right now" verdict for the pull warning
# guard (bin/fm-guard.sh), NOT the arm layer or the turn-end guard. Sets:
Expand Down
5 changes: 4 additions & 1 deletion docs/turnend-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ The default cross-harness mode exits silently with no supervision need.
Every mode treats `state/x-watch.check.sh` as supervision need, so Relay polling remains guarded without an in-flight task.
Otherwise it calls `fm_watcher_healthy <state-dir> <watch-path> [grace-seconds] [home]` from `bin/fm-wake-lib.sh`, the same PID-strict identity-matched lock and fresh-beacon check used by `bin/fm-watch-arm.sh`: a stale beacon blocks even when a watcher pid is live, and a fresh leftover beacon blocks when the lock is missing, dead, or identity-mismatched.
The turn-end guard needs that strict check because it fires at the turn boundary, where the auto-arm is bringing a fresh watcher up for the upcoming idle period, and it cooperates with that arm rather than trusting a beacon left by the cycle that just ended.
When `fm_watcher_healthy` fails, the guard also accepts `fm_afk_daemon_owns_supervision <state> [grace-seconds]` (also in `bin/fm-wake-lib.sh`) as an alternate liveness proof, in every harness mode.
`bin/fm-supervise-daemon.sh` tears its watcher down and relaunches it after every actionable wake and after a bounded crash-loop backoff, so `state/.watch.lock` is routinely and briefly dead or genuinely unheld between watcher generations while away mode (`state/.afk`) is active, even though the daemon and its beacon are healthy the whole time.
That check requires away mode active, a live identity-matched `state/.supervise-daemon.lock` holder, and a beacon fresh within grace - the same three-part shape as the Pi extension exception below - so a genuinely dead daemon or a stale beacon still blocks exactly as before.
`bin/fm-guard.sh`, the pull warning, instead uses the model-aware `fm_watcher_supervision_verdict` from the same library, because it fires mid-turn when the auto-arm model runs no watcher at all.
Under the Claude Stop auto-arm model a beacon fresh within grace is healthy even with no live watcher process, and only a beacon stale beyond grace (or absent) alarms.
Under the Pi extension model a live identity-matched watcher is the ordinary healthy state, but a genuinely unheld lock with a beacon fresh within grace is also healthy while a live Pi session provably owns continuity, because `.pi/extensions/fm-primary-pi-watch.ts` tears the watcher down on every actionable wake and spawns the replacement itself.
Expand Down Expand Up @@ -153,7 +156,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` claim wait, monotonic failed-epoch progression, bounded attended fail-open, post-alarm continuation suppression, positive recovery reset, the abandoned auto-arm claim cases that must block or clear instead of allowing a blind stop, 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 away-mode-daemon-owned watcher exception in both `--claude` and default mode alongside its dead-daemon and stale-beacon negative controls, the cooperative `--claude` claim wait, monotonic failed-epoch progression, bounded attended fail-open, post-alarm continuation suppression, positive recovery reset, the abandoned auto-arm claim cases that must block or clear instead of allowing a blind stop, 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 and stale-beacon alarm, 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, child-worktree exclusion, and that the adapter never exits 2.
Expand Down
106 changes: 106 additions & 0 deletions tests/fm-turnend-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,15 @@ record_watcher_lock() {
printf '%s\n' "$identity" > "$dir/state/.watch.lock/pid-identity"
}

# Simulates bin/fm-supervise-daemon.sh's own lock, the away-mode sub-supervisor
# that tears the watcher down and relaunches it on every actionable wake.
record_daemon_lock() {
local dir=$1 pid=$2 identity=$3
mkdir -p "$dir/state/.supervise-daemon.lock"
printf '%s\n' "$pid" > "$dir/state/.supervise-daemon.lock/pid"
printf '%s\n' "$identity" > "$dir/state/.supervise-daemon.lock/pid-identity"
}

test_hook_silent_when_no_work_in_flight() {
local dir out status
dir=$(make_primary_dir "$TMP_ROOT/hook-idle")
Expand Down Expand Up @@ -1608,6 +1617,99 @@ test_hook_claude_mode_away_mode_never_uses_stop_autoarm_fail_open() {
pass "fm-turnend-guard --claude: away ownership excludes the Stop-autoarm fail-open"
}

# Regression for the 2026-08-22 false positive (data/learnings.md): the daemon
# tears its watcher down and relaunches it on every actionable wake and after a
# bounded crash-loop backoff, so state/.watch.lock is routinely and briefly
# dead or genuinely unheld between watcher generations even while the daemon
# and its beacon are perfectly healthy. A live identity-matched daemon lock
# with a fresh beacon must satisfy the guard exactly like a live watcher lock.
test_hook_claude_mode_allows_when_afk_daemon_owns_recovery() {
local dir pid identity out status
dir=$(make_primary_dir "$TMP_ROOT/hook-claude-afk-daemon-owns")
: > "$dir/state/task1.meta"
: > "$dir/state/.afk"
sleep 60 &
pid=$!
identity=$(watcher_identity "$dir" "$pid") || {
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fail "could not identify live daemon holder"
}
record_daemon_lock "$dir" "$pid" "$identity"
touch "$dir/state/.last-watcher-beat"
# state/.watch.lock itself stays absent: the watcher is genuinely between
# generations, the exact benign gap the daemon is actively closing.
out=$(run_hook_claude "$dir" false); status=$?
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
expect_code 0 "$status" "--claude must allow when a live afk daemon owns recovery, even with no watcher lock"
[ -z "$out" ] || fail "--claude produced output despite a live daemon-owned recovery: $out"
pass "fm-turnend-guard --claude: a live afk daemon with a fresh beacon satisfies the guard with no watcher lock"
}

test_hook_default_mode_allows_when_afk_daemon_owns_recovery() {
local dir pid identity out status
dir=$(make_primary_dir "$TMP_ROOT/hook-default-afk-daemon-owns")
: > "$dir/state/task1.meta"
: > "$dir/state/.afk"
sleep 60 &
pid=$!
identity=$(watcher_identity "$dir" "$pid") || {
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fail "could not identify live daemon holder"
}
record_daemon_lock "$dir" "$pid" "$identity"
touch "$dir/state/.last-watcher-beat"
out=$(run_hook "$dir" false); status=$?
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
expect_code 0 "$status" "default mode must allow when a live afk daemon owns recovery, even with no watcher lock"
[ -z "$out" ] || fail "default mode produced output despite a live daemon-owned recovery: $out"
pass "fm-turnend-guard: a live afk daemon with a fresh beacon satisfies the default-mode guard too"
}

# Daemon-ownership tolerance must not paper over a genuine outage: a dead
# daemon lock (the daemon itself crashed, not merely mid-restart) still blocks.
test_hook_claude_mode_blocks_when_afk_daemon_is_dead() {
local dir dead out status
dir=$(make_primary_dir "$TMP_ROOT/hook-claude-afk-daemon-dead")
: > "$dir/state/task1.meta"
: > "$dir/state/.afk"
dead=$(nonexistent_pid)
record_daemon_lock "$dir" "$dead" "dead daemon identity"
touch "$dir/state/.last-watcher-beat"
out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" false); status=$?
expect_code 2 "$status" "--claude must still block when the afk daemon lock is dead despite a fresh beacon"
assert_contains "$out" 'Away mode owns watcher supervision' "afk block reason lost its daemon ownership guidance"
pass "fm-turnend-guard --claude: a dead afk daemon lock does not satisfy the guard"
}

# Nor may it paper over a genuine wedge: a live daemon with an ancient beacon
# (the daemon itself is up but supervision has not ticked in a long time)
# still blocks.
test_hook_claude_mode_blocks_when_afk_daemon_beacon_is_stale() {
local dir pid identity out status
dir=$(make_primary_dir "$TMP_ROOT/hook-claude-afk-daemon-stale")
: > "$dir/state/task1.meta"
: > "$dir/state/.afk"
sleep 60 &
pid=$!
identity=$(watcher_identity "$dir" "$pid") || {
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fail "could not identify live daemon holder"
}
record_daemon_lock "$dir" "$pid" "$identity"
touch -t 202001010000 "$dir/state/.last-watcher-beat"
out=$(FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=100 run_hook_claude "$dir" false); status=$?
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
expect_code 2 "$status" "--claude must still block when a live afk daemon has an ancient beacon"
assert_contains "$out" 'Away mode owns watcher supervision' "afk block reason lost its daemon ownership guidance"
pass "fm-turnend-guard --claude: a live afk daemon with an ancient beacon does not satisfy the guard"
}

test_hook_claude_mode_allow_resets_budget() {
local dir pid identity out status
dir=$(make_primary_dir "$TMP_ROOT/hook-claude-reset")
Expand Down Expand Up @@ -1742,6 +1844,10 @@ test_hook_claude_mode_budget_without_verified_failure_keeps_blocking
test_hook_claude_mode_verified_failure_alarm_is_loud_and_once
test_hook_claude_mode_fail_open_requires_notice_and_failure_epoch
test_hook_claude_mode_away_mode_never_uses_stop_autoarm_fail_open
test_hook_claude_mode_allows_when_afk_daemon_owns_recovery
test_hook_default_mode_allows_when_afk_daemon_owns_recovery
test_hook_claude_mode_blocks_when_afk_daemon_is_dead
test_hook_claude_mode_blocks_when_afk_daemon_beacon_is_stale
test_hook_claude_mode_allow_resets_budget
test_hook_claude_mode_waits_for_late_claim
test_hook_claude_mode_secondmate_reblocks_like_primary