From 8f72c2d1171cbe747c19d9d8ad981f904c7de6c1 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:34:26 +0000 Subject: [PATCH 1/6] fix: verify away pusher at turn end --- bin/fm-turnend-guard.sh | 17 ++++++---- bin/fm-wake-lib.sh | 23 +++++++++++++ docs/turnend-guard.md | 12 +++++-- tests/fm-turnend-guard.test.sh | 60 ++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 1038a90702..1f7f21a5ae 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -86,18 +86,17 @@ fm_supervision_status "$STATE" "$GRACE" [ "$FM_SUP_IN_FLIGHT" -gt 0 ] || exit 0 daemon_healthy=0 delivery_armed=0 +afk=0 +[ -e "$STATE/.afk" ] && afk=1 fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME" && daemon_healthy=1 -if [ -e "$STATE/.afk" ]; then +if [ "$afk" -eq 1 ]; then # Away mode's own daemon consumes new durable queue records; normal session # delivery waits remain intentionally paused under the /afk contract. - delivery_armed=1 + fm_pusher_healthy "$STATE" && delivery_armed=1 elif fm_wake_stub_armed "$STATE" "$STUB" "$FM_HOME"; then delivery_armed=1 fi [ "$daemon_healthy" -eq 1 ] && [ "$delivery_armed" -eq 1 ] && exit 0 - -afk=0 -[ -e "$STATE/.afk" ] && afk=1 x_mode=0 [ -f "$CONFIG/x-mode.env" ] && x_mode=1 DELIVERY_REASON=$("$SCRIPT_DIR/fm-supervision-instructions.sh" --afk "$afk" --x-mode "$x_mode" --repair-line 2>/dev/null \ @@ -113,10 +112,14 @@ rule='━━━━━━━━━━━━━━━━━━━━━━━━ printf '● Daemon repair: %s; treat this as a supervision incident and verify the beacon+lock predicate.\n' "$DAEMON_REASON" fi if [ "$delivery_armed" -eq 0 ]; then - printf '● Wake delivery missing: no identity-matched delivery stub is armed for this session.\n' + if [ "$afk" -eq 1 ]; then + printf '● Away wake delivery missing: no live identity-matched pusher holds the supervise-daemon lock.\n' + else + printf '● Wake delivery missing: no identity-matched delivery stub is armed for this session.\n' + fi printf '● Delivery repair: %s\n' "$DELIVERY_REASON" fi - printf '● This forced continuation is internal maintenance; after draining and re-arming, end silently unless a queued wake is captain-relevant under AGENTS.md section 9.\n' + printf '● This forced continuation is internal maintenance; after draining and restoring delivery, end silently unless a queued wake is captain-relevant under AGENTS.md section 9.\n' printf '●%s\n' "$rule" } >&2 exit 2 diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 4a33966264..e0ac4ea0f7 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -133,6 +133,29 @@ fm_watcher_healthy() { return 0 } +fm_pusher_lock_matches_pid() { + local state=$1 pid=$2 lockdir lock_pid lock_identity current_identity + lockdir="$state/.supervise-daemon.lock" + lock_pid=$(cat "$lockdir/pid" 2>/dev/null || true) + lock_identity=$(cat "$lockdir/pid-identity" 2>/dev/null || true) + [ "$lock_pid" = "$pid" ] || return 1 + [ -n "$lock_identity" ] || return 1 + current_identity=$(fm_pid_identity "$pid") || return 1 + [ "$current_identity" = "$lock_identity" ] +} + +FM_PUSHER_HEALTHY_PID= +fm_pusher_healthy() { + local state=$1 pid + FM_PUSHER_HEALTHY_PID= + pid=$(cat "$state/.supervise-daemon.pid" 2>/dev/null || true) + fm_pid_alive "$pid" || return 1 + fm_pusher_lock_matches_pid "$state" "$pid" || return 1 + # shellcheck disable=SC2034 # Read by callers after fm_pusher_healthy returns. + FM_PUSHER_HEALTHY_PID=$pid + return 0 +} + fm_lock_clean_known_files() { local lockdir=$1 rm -f \ diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index a00d422239..cd9d54e45a 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -37,7 +37,15 @@ The stub needs no beacon because it is a pure blocking wait over the durable que Daemon failure and delivery failure produce separate repair lines. The daemon line names the scoped systemd instance restart or tmux keeper repair and is treated as a real supervision incident. The delivery line points to the active harness protocol and costs one cheap re-arm. -While `state/.afk` is present, the away daemon consumes new durable queue records and intentionally satisfies the delivery half without a session stub; daemon health remains mandatory. +While `state/.afk` is present, the away daemon consumes new durable queue records and intentionally replaces the session stub. +The away flag alone does not satisfy the delivery half. +`fm_pusher_healthy ` requires `state/.supervise-daemon.pid` to name a live pid that matches the portable lock's pid and recorded process identity. +Watcher health remains independently mandatory. + +If the away daemon dies during an unattended stretch, the next attempted turn end forces one maintenance continuation that directs the agent to restore away delivery. +The hook loop guard then permits that turn to stop, so daemon death cannot create an autonomous endless continuation loop. +A later independent turn alerts again until delivery is repaired. +The daemon-owned max-defer alarm cannot run after the daemon itself dies, so it is not treated as substitute coverage; queued wakes remain durable and the turn-end force is the recovery signal. `FM_STATE_OVERRIDE` wins over `FM_HOME/state`, and `FM_HOME` wins over repo-root `state/`. `FM_GUARD_GRACE` controls only the daemon beacon freshness window and defaults to 300 seconds. @@ -197,7 +205,7 @@ Firstmate deliberately does not track `.claude/settings.local.json` anywhere, an ## Tests -`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, away-mode delivery ownership, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. +`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, dead and healthy away-pusher states with queued wakes, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. The per-task crewmate overlay recorded in the 2026-07-25 subsection is pinned from the spawn side by `tests/fm-spawn-dispatch-profile.test.sh` (a repository-tracked `.claude/settings.local.json` survives a claude spawn, the overlay is written under the distinct name and git-excluded, and the launch line carries `--settings` for it) and from the teardown side by `tests/fm-teardown.test.sh` cases (z) through (dd) (a tracked-and-dirty overlay still tears down, that tolerance does not mask other genuine uncommitted work, and a repository-tracked legacy `settings.local.json` is never removed). The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 7b99ba037b..da260331a0 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -274,6 +274,14 @@ record_stub_lock() { printf '%s\n' "$identity" > "$dir/state/.wake-stub.lock/pid-identity" } +record_pusher_lock() { + local dir=$1 pid=$2 identity=$3 + mkdir -p "$dir/state/.supervise-daemon.lock" + printf '%s\n' "$pid" > "$dir/state/.supervise-daemon.pid" + 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") @@ -348,6 +356,56 @@ test_hook_blocks_with_live_daemon_but_no_stub() { pass "fm-turnend-guard: healthy daemon without a session delivery stub blocks with the cheap re-arm repair" } +test_hook_afk_blocks_with_dead_pusher_and_queued_wakes() { + local dir watcher_pid watcher_identity dead out status queue_lines + dir=$(make_primary_dir "$TMP_ROOT/hook-afk-dead-pusher") + : > "$dir/state/task1.meta" + : > "$dir/state/.afk" + printf '1\t1\tsignal\ttask1.status\tdone: one\n1\t2\tsignal\ttask2.status\tdone: two\n1\t3\tcheck\ttask3\tcheck: three\n' > "$dir/state/.wake-queue" + sleep 60 & + watcher_pid=$! + watcher_identity=$(watcher_identity "$dir" "$watcher_pid") || fail "could not identify live watcher holder" + record_watcher_lock "$dir" "$watcher_pid" "$watcher_identity" + touch "$dir/state/.last-watcher-beat" + dead=$(nonexistent_pid) + record_pusher_lock "$dir" "$dead" "dead pusher identity" + out=$(run_hook "$dir" false); status=$? + queue_lines=$(wc -l < "$dir/state/.wake-queue") + kill "$watcher_pid" 2>/dev/null || true + wait "$watcher_pid" 2>/dev/null || true + expect_code 2 "$status" "away hook must block when the pusher is dead with queued wakes" + assert_contains "$out" "Away wake delivery missing" "away hook did not identify the dead pusher" + assert_not_contains "$out" "Watcher daemon down" "away hook falsely reported the healthy watcher down" + [ "$queue_lines" -eq 3 ] || fail "away hook changed the queued wakes while checking pusher health" + pass "fm-turnend-guard: dead away pusher with queued wakes blocks the turn" +} + +test_hook_afk_silent_with_healthy_pusher() { + local dir watcher_pid watcher_identity pusher_pid pusher_identity out status queue_lines + dir=$(make_primary_dir "$TMP_ROOT/hook-afk-healthy-pusher") + : > "$dir/state/task1.meta" + : > "$dir/state/.afk" + printf '1\t1\tsignal\ttask1.status\tdone: queued\n' > "$dir/state/.wake-queue" + sleep 60 & + watcher_pid=$! + sleep 60 & + pusher_pid=$! + watcher_identity=$(watcher_identity "$dir" "$watcher_pid") || fail "could not identify live watcher holder" + pusher_identity=$(watcher_identity "$dir" "$pusher_pid") || fail "could not identify live pusher holder" + record_watcher_lock "$dir" "$watcher_pid" "$watcher_identity" + record_pusher_lock "$dir" "$pusher_pid" "$pusher_identity" + touch "$dir/state/.last-watcher-beat" + out=$(run_hook "$dir" false); status=$? + queue_lines=$(wc -l < "$dir/state/.wake-queue") + kill "$watcher_pid" "$pusher_pid" 2>/dev/null || true + wait "$watcher_pid" 2>/dev/null || true + wait "$pusher_pid" 2>/dev/null || true + expect_code 0 "$status" "away hook must allow the turn with a healthy identity-matched pusher" + [ -z "$out" ] || fail "away hook produced output despite a healthy identity-matched pusher: $out" + [ "$queue_lines" -eq 1 ] || fail "away hook changed the queued wake while checking pusher health" + pass "fm-turnend-guard: healthy identity-matched away pusher allows the turn" +} + test_hook_blocks_with_live_lock_and_stale_beacon() { local dir pid identity out status dir=$(make_primary_dir "$TMP_ROOT/hook-live-lock-stale") @@ -1064,6 +1122,8 @@ test_hook_blocks_when_fresh_beacon_has_no_live_lock test_hook_blocks_when_dead_lock_has_fresh_beacon test_hook_silent_with_live_lock_and_fresh_beacon test_hook_blocks_with_live_daemon_but_no_stub +test_hook_afk_blocks_with_dead_pusher_and_queued_wakes +test_hook_afk_silent_with_healthy_pusher test_hook_blocks_with_live_lock_and_stale_beacon test_hook_blocks_when_unhealthy_in_primary test_hook_blocks_from_fm_home_state From bdb1eb9a1eb2902e58aad12e9e8110a22acb8830 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:53:49 +0000 Subject: [PATCH 2/6] no-mistakes(review): activate turn-end guard on queued wakes; name away restart --- bin/fm-supervision-instructions.sh | 2 +- bin/fm-turnend-guard.sh | 13 ++++- docs/turnend-guard.md | 9 ++-- tests/fm-daemon.test.sh | 60 +++++++++++++++++++++++ tests/fm-supervision-instructions.test.sh | 6 +++ tests/fm-turnend-guard.test.sh | 27 ++++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index bee96ff086..148659edaf 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -110,7 +110,7 @@ repair_line() { return 0 fi if [ "$AFK" -eq 1 ]; then - printf '%s\n' 'Away mode owns wake delivery; load /afk and ensure the daemon is reading the durable queue instead of arming a session delivery wait.' + printf '%s\n' 'Away mode owns wake delivery and no live identity-matched away daemon is reading the durable queue: restart it with bin/fm-afk-launch.sh start, then confirm state/.supervise-daemon.pid names a live pid matching the daemon lock identity; do not arm a session delivery wait instead.' return 0 fi diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 1f7f21a5ae..7286ab4685 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -83,7 +83,11 @@ fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 . "$SCRIPT_DIR/fm-wake-lib.sh" fm_supervision_status "$STATE" "$GRACE" -[ "$FM_SUP_IN_FLIGHT" -gt 0 ] || exit 0 +# A queued durable wake is protected work in its own right: state/*.meta records +# can be cleaned up while terminal wakes still sit unread in state/.wake-queue. +if [ "$FM_SUP_IN_FLIGHT" -eq 0 ] && [ "$FM_SUP_QUEUE_PENDING" != true ]; then + exit 0 +fi daemon_healthy=0 delivery_armed=0 afk=0 @@ -103,12 +107,17 @@ DELIVERY_REASON=$("$SCRIPT_DIR/fm-supervision-instructions.sh" --afk "$afk" --x- || printf '%s\n' 're-arm wake delivery according to the session-start operating block before ending the turn') DAEMON_REASON=$("$SCRIPT_DIR/fm-watcher-service.sh" repair-command 2>/dev/null \ || printf '%s\n' 'bin/fm-watcher-service.sh restart') +if [ "$FM_SUP_IN_FLIGHT" -gt 0 ]; then + protected_desc="$FM_SUP_IN_FLIGHT task(s) in flight" +else + protected_desc='queued wake(s) pending in the durable queue' +fi rule='━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' { printf '●%s\n' "$rule" printf '● TURN WOULD END BLIND - SUPERVISION IS INCOMPLETE\n' if [ "$daemon_healthy" -eq 0 ]; then - printf '● Watcher daemon down: %s task(s) in flight and no healthy daemon holds this home lock (last beat: %s).\n' "$FM_SUP_IN_FLIGHT" "$FM_SUP_BEACON_DESC" + printf '● Watcher daemon down: %s and no healthy daemon holds this home lock (last beat: %s).\n' "$protected_desc" "$FM_SUP_BEACON_DESC" printf '● Daemon repair: %s; treat this as a supervision incident and verify the beacon+lock predicate.\n' "$DAEMON_REASON" fi if [ "$delivery_armed" -eq 0 ]; then diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index cd9d54e45a..c6da7b601c 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -27,8 +27,9 @@ It also requires `AGENTS.md`, `bin/`, and the effective state directory to exist For an in-scope primary checkout, it counts supervision-relevant work from `state/*.meta`. A `kind=secondmate` record with explicit `state=resting` remains registered but is excluded; every ordinary task, active secondmate, and legacy secondmate with no state still counts. -If no supervision-relevant work remains, it exits silently. -If work is in flight, the first half requires `fm_watcher_healthy [grace-seconds] [home]` from `bin/fm-wake-lib.sh`. +An unread record in `state/.wake-queue` is protected work in its own right, because a terminal wake can outlive the `state/*.meta` record it came from: the guard stays active whenever `FM_SUP_IN_FLIGHT` is above zero **or** `FM_SUP_QUEUE_PENDING` is true, and the daemon-down banner then names the queued wakes instead of an in-flight count. +If neither remains, it exits silently. +If work is protected, the first half requires `fm_watcher_healthy [grace-seconds] [home]` from `bin/fm-wake-lib.sh`. That daemon predicate is unchanged: it requires an identity-matched live watcher lock and a fresh beacon. A stale beacon blocks even if the watcher pid is still live, and a fresh leftover beacon blocks if the watcher lock is missing, dead, or identity-mismatched. The second half requires `fm_wake_stub_armed [home]` from the same library. @@ -40,7 +41,9 @@ The delivery line points to the active harness protocol and costs one cheap re-a While `state/.afk` is present, the away daemon consumes new durable queue records and intentionally replaces the session stub. The away flag alone does not satisfy the delivery half. `fm_pusher_healthy ` requires `state/.supervise-daemon.pid` to name a live pid that matches the portable lock's pid and recorded process identity. +Those are exactly the three files `bin/fm-supervise-daemon.sh` publishes at startup, and `tests/fm-daemon.test.sh` pins the predicate against that real publication rather than a fixture. Watcher health remains independently mandatory. +The away delivery repair line names the concrete restart, `bin/fm-afk-launch.sh start`, and never tells an away session to arm a session delivery wait. If the away daemon dies during an unattended stretch, the next attempted turn end forces one maintenance continuation that directs the agent to restore away delivery. The hook loop guard then permits that turn to stop, so daemon death cannot create an autonomous endless continuation loop. @@ -205,7 +208,7 @@ Firstmate deliberately does not track `.claude/settings.local.json` anywhere, an ## Tests -`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, dead and healthy away-pusher states with queued wakes, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. +`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, dead and healthy away-pusher states with queued wakes, queued wakes with no `state/*.meta` record left, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. The per-task crewmate overlay recorded in the 2026-07-25 subsection is pinned from the spawn side by `tests/fm-spawn-dispatch-profile.test.sh` (a repository-tracked `.claude/settings.local.json` survives a claude spawn, the overlay is written under the distinct name and git-excluded, and the launch line carries `--settings` for it) and from the teardown side by `tests/fm-teardown.test.sh` cases (z) through (dd) (a tracked-and-dirty overlay still tears down, that tolerance does not mask other genuine uncommitted work, and a repository-tracked legacy `settings.local.json` is never removed). The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index c6db99791c..ebb55b554e 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -75,6 +75,65 @@ test_afk_start_reclaims_stale_daemon_lock_reused_pid() { pass "fm-afk-start.sh reclaims stale daemon locks whose live pid identity no longer matches" } +# Run bin/fm-wake-lib.sh's away-pusher predicate in a child shell: the library +# resolves its own state root at source time, and this file only sources the +# daemon's classifiers. +pusher_healthy_status() { + local state=$1 + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pusher_healthy "$2"' _ "$ROOT/bin/fm-wake-lib.sh" "$state" +} + +pid_identity_of() { + local state=$1 pid=$2 + FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$ROOT/bin/fm-wake-lib.sh" "$pid" +} + +# The turn-end guard's away half (bin/fm-turnend-guard.sh -> fm_pusher_healthy) +# reads the pidfile and lock identity the daemon publishes itself. Pin the +# predicate to that REAL publication, so a drift in either daemon-side write +# fails here instead of silently reading a healthy away daemon as dead. +test_pusher_healthy_accepts_real_daemon_publication() { + local dir state fakebin pid i live_rc dead_rc published_pid lock_pid lock_identity live_identity + dir=$(make_supercase pusher-healthy-real-daemon) + state="$dir/state" + fakebin="$dir/fakebin" + + PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=tmux \ + FM_SUPERVISOR_TARGET=fakepane FM_FAKE_TMUX_PANE_ALIVE=1 \ + "$DAEMON" > "$dir/daemon.out" 2> "$dir/daemon.err" & + pid=$! + i=0 + while [ "$i" -lt 100 ]; do + if [ -s "$state/.supervise-daemon.pid" ] && [ -s "$state/.supervise-daemon.lock/pid-identity" ]; then + break + fi + is_live_non_zombie "$pid" || break + sleep 0.1 + i=$((i + 1)) + done + + published_pid=$(cat "$state/.supervise-daemon.pid" 2>/dev/null || true) + lock_pid=$(cat "$state/.supervise-daemon.lock/pid" 2>/dev/null || true) + lock_identity=$(cat "$state/.supervise-daemon.lock/pid-identity" 2>/dev/null || true) + live_identity=$(pid_identity_of "$state" "$pid" || true) + live_rc=0 + pusher_healthy_status "$state" || live_rc=$? + + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + dead_rc=0 + pusher_healthy_status "$state" || dead_rc=$? + + [ "$published_pid" = "$pid" ] \ + || fail "daemon pidfile does not name the running daemon (pidfile '$published_pid', daemon $pid): $(cat "$dir/daemon.err" 2>/dev/null)" + [ "$lock_pid" = "$pid" ] || fail "daemon lock pid does not name the running daemon: '$lock_pid'" + [ -n "$lock_identity" ] || fail "daemon published an empty lock pid-identity, which reads as a dead pusher" + [ "$lock_identity" = "$live_identity" ] || fail "published lock identity does not match the live daemon identity" + [ "$live_rc" -eq 0 ] || fail "fm_pusher_healthy rejected the real away daemon's own published pidfile and lock" + [ "$dead_rc" -ne 0 ] || fail "fm_pusher_healthy still reported a healthy pusher after the daemon exited" + pass "fm_pusher_healthy accepts the real away daemon's published pidfile and lock identity, and rejects it after shutdown" +} + test_daemon_state_root_uses_fm_home() { local dir home override out dir=$(make_supercase daemon-fm-home) @@ -1717,6 +1776,7 @@ test_inject_msg_defers_on_dead_shell_unknown() { test_afk_start_refuses_when_flag_cannot_be_written test_afk_start_ignores_stale_pidfile_without_lock test_afk_start_reclaims_stale_daemon_lock_reused_pid +test_pusher_healthy_accepts_real_daemon_publication test_daemon_state_root_uses_fm_home test_classify_routine_signal_self test_classify_terminal_signal_escalates diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index 0912fe558d..be563a4ce1 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -60,6 +60,12 @@ test_repair_lines() { assert_not_contains "$out" "source '$home/config/x-mode.env' first" "x-mode delivery repair still sourced daemon-owned cadence" assert_contains "$out" "bin/fm-watch-checkpoint.sh --seconds 7" "x-mode codex repair line lost the checkpoint helper" + out=$(FM_HOME="$home" "$RENDER" --harness claude --afk 1 --repair-line) + assert_contains "$out" "bin/fm-afk-launch.sh start" "away repair line does not name the concrete away-daemon restart" + assert_contains "$out" "no live identity-matched away daemon" "away repair line does not describe the dead-daemon situation" + assert_not_contains "$out" "load /afk" "away repair line still described the old delivery-ownership contract" + assert_not_contains "$out" "bin/fm-watch-arm.sh" "away repair line leaked the session-stub re-arm command" + out=$(FM_HOME="$home" "$RENDER" --harness opencode --read-only 1 --repair-line) assert_contains "$out" "session holding the fleet lock" "read-only repair line missing" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index da260331a0..381ce595fa 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -22,6 +22,7 @@ fm_git_identity fmtest fmtest@example.invalid REQUIRED_REASON='re-arm wake delivery with bin/fm-watch-arm.sh as its own Claude Code background task' SILENT_REASON='This forced continuation is internal maintenance' +AWAY_REPAIR_REASON='restart it with bin/fm-afk-launch.sh start' # --- PREDICATE: bin/fm-supervision-lib.sh ----------------------------------- @@ -376,10 +377,35 @@ test_hook_afk_blocks_with_dead_pusher_and_queued_wakes() { expect_code 2 "$status" "away hook must block when the pusher is dead with queued wakes" assert_contains "$out" "Away wake delivery missing" "away hook did not identify the dead pusher" assert_not_contains "$out" "Watcher daemon down" "away hook falsely reported the healthy watcher down" + assert_contains "$out" "$AWAY_REPAIR_REASON" "away repair line did not name the concrete away-daemon restart" + assert_not_contains "$out" "load /afk" "away repair line still described the old delivery-ownership contract" [ "$queue_lines" -eq 3 ] || fail "away hook changed the queued wakes while checking pusher health" pass "fm-turnend-guard: dead away pusher with queued wakes blocks the turn" } +# Terminal wakes can outlive their state/*.meta records (the drain/cleanup path +# removes the meta while the queue record is still unread), so the queue alone +# must keep the guard active. +test_hook_afk_blocks_with_queued_wakes_and_no_meta() { + local dir dead out status queue_lines + dir=$(make_primary_dir "$TMP_ROOT/hook-afk-queued-no-meta") + : > "$dir/state/.afk" + printf '1\t1\tsignal\ttask1.status\tdone: one\n1\t2\tcheck\ttask2\tcheck: two\n' > "$dir/state/.wake-queue" + dead=$(nonexistent_pid) + record_pusher_lock "$dir" "$dead" "dead pusher identity" + out=$(run_hook "$dir" false); status=$? + queue_lines=$(wc -l < "$dir/state/.wake-queue") + [ -z "$(find "$dir/state" -maxdepth 1 -name '*.meta' -print -quit)" ] \ + || fail "fixture must carry zero state/*.meta records" + expect_code 2 "$status" "away hook must block on queued wakes with a dead pusher and zero meta records" + assert_contains "$out" "Away wake delivery missing" "queued-wake block did not identify the dead pusher" + assert_contains "$out" "$AWAY_REPAIR_REASON" "queued-wake block did not name the concrete away-daemon restart" + assert_contains "$out" "Watcher daemon down: queued wake(s) pending" "banner did not name the queued wakes as the protected work" + assert_not_contains "$out" "0 task(s) in flight" "banner claimed an in-flight count it does not have" + [ "$queue_lines" -eq 2 ] || fail "away hook changed the queued wakes while checking pusher health" + pass "fm-turnend-guard: queued wakes with no state/*.meta keep the guard active for a dead away pusher" +} + test_hook_afk_silent_with_healthy_pusher() { local dir watcher_pid watcher_identity pusher_pid pusher_identity out status queue_lines dir=$(make_primary_dir "$TMP_ROOT/hook-afk-healthy-pusher") @@ -1123,6 +1149,7 @@ test_hook_blocks_when_dead_lock_has_fresh_beacon test_hook_silent_with_live_lock_and_fresh_beacon test_hook_blocks_with_live_daemon_but_no_stub test_hook_afk_blocks_with_dead_pusher_and_queued_wakes +test_hook_afk_blocks_with_queued_wakes_and_no_meta test_hook_afk_silent_with_healthy_pusher test_hook_blocks_with_live_lock_and_stale_beacon test_hook_blocks_when_unhealthy_in_primary From 826b353f4b3d3a1ae5f0492c62409a87905e7fe3 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:05:22 +0000 Subject: [PATCH 3/6] no-mistakes(review): make away repair harness-aware; name drain; bound daemon wait --- bin/fm-supervision-instructions.sh | 26 ++++++++++++++++++++++- bin/fm-turnend-guard.sh | 5 ++++- docs/turnend-guard.md | 8 +++++-- tests/fm-daemon.test.sh | 7 ++++-- tests/fm-supervision-instructions.test.sh | 20 ++++++++++++++++- tests/fm-turnend-guard.test.sh | 26 ++++++++++++++++++++++- 6 files changed, 84 insertions(+), 8 deletions(-) diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 148659edaf..98ec59a1e0 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -104,13 +104,37 @@ render_snippet() { done < "$SNIPPET" } +# Away delivery is the daemon, not a session stub, so this branch names the +# daemon relaunch the /afk skill prescribes for the resolved harness: the +# no-separate-terminal native path where the harness owns a tracked background +# job, and the terminal-backed launcher everywhere else. Draining stays out of +# it: the away daemon reads the durable queue through its own cursor. +away_repair_line() { + local relaunch + case "$HARNESS" in + claude) + relaunch='prepare the lifecycle with bin/fm-afk-launch.sh start-native, then run FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own Claude Code background task (never shell &), rolling the preparation back with bin/fm-afk-launch.sh stop if that native launch fails' + ;; + grok) + relaunch='prepare the lifecycle with bin/fm-afk-launch.sh start-native, then run FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own Grok tracked background task (never shell &), rolling the preparation back with bin/fm-afk-launch.sh stop if that native launch fails' + ;; + *) + relaunch='restart its non-visible tracked terminal with bin/fm-afk-launch.sh start' + ;; + esac + printf '%s%s%s\n' \ + 'Away mode owns wake delivery and no live identity-matched away daemon is reading the durable queue: ' \ + "$relaunch" \ + '. Then confirm state/.supervise-daemon.pid names a live pid matching the daemon lock identity; do not arm a session delivery wait instead.' +} + repair_line() { if [ "$READ_ONLY" -eq 1 ]; then printf '%s\n' 'Watcher repair belongs to the session holding the fleet lock; do not drain, arm, or repair from this read-only session.' return 0 fi if [ "$AFK" -eq 1 ]; then - printf '%s\n' 'Away mode owns wake delivery and no live identity-matched away daemon is reading the durable queue: restart it with bin/fm-afk-launch.sh start, then confirm state/.supervise-daemon.pid names a live pid matching the daemon lock identity; do not arm a session delivery wait instead.' + away_repair_line return 0 fi diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 7286ab4685..7568ac2666 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -103,7 +103,10 @@ fi [ "$daemon_healthy" -eq 1 ] && [ "$delivery_armed" -eq 1 ] && exit 0 x_mode=0 [ -f "$CONFIG/x-mode.env" ] && x_mode=1 -DELIVERY_REASON=$("$SCRIPT_DIR/fm-supervision-instructions.sh" --afk "$afk" --x-mode "$x_mode" --repair-line 2>/dev/null \ +queue_pending=0 +[ "$FM_SUP_QUEUE_PENDING" = true ] && queue_pending=1 +DELIVERY_REASON=$("$SCRIPT_DIR/fm-supervision-instructions.sh" --afk "$afk" --x-mode "$x_mode" \ + --queue-pending "$queue_pending" --repair-line 2>/dev/null \ || printf '%s\n' 're-arm wake delivery according to the session-start operating block before ending the turn') DAEMON_REASON=$("$SCRIPT_DIR/fm-watcher-service.sh" repair-command 2>/dev/null \ || printf '%s\n' 'bin/fm-watcher-service.sh restart') diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index c6da7b601c..85125478a7 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -28,6 +28,7 @@ It also requires `AGENTS.md`, `bin/`, and the effective state directory to exist For an in-scope primary checkout, it counts supervision-relevant work from `state/*.meta`. A `kind=secondmate` record with explicit `state=resting` remains registered but is excluded; every ordinary task, active secondmate, and legacy secondmate with no state still counts. An unread record in `state/.wake-queue` is protected work in its own right, because a terminal wake can outlive the `state/*.meta` record it came from: the guard stays active whenever `FM_SUP_IN_FLIGHT` is above zero **or** `FM_SUP_QUEUE_PENDING` is true, and the daemon-down banner then names the queued wakes instead of an in-flight count. +That queue state is also passed to the repair-line renderer as `--queue-pending`, exactly as `bin/fm-guard.sh` does, so a session-delivery repair says the queued wakes must be drained before re-arming. If neither remains, it exits silently. If work is protected, the first half requires `fm_watcher_healthy [grace-seconds] [home]` from `bin/fm-wake-lib.sh`. That daemon predicate is unchanged: it requires an identity-matched live watcher lock and a fresh beacon. @@ -43,7 +44,10 @@ The away flag alone does not satisfy the delivery half. `fm_pusher_healthy ` requires `state/.supervise-daemon.pid` to name a live pid that matches the portable lock's pid and recorded process identity. Those are exactly the three files `bin/fm-supervise-daemon.sh` publishes at startup, and `tests/fm-daemon.test.sh` pins the predicate against that real publication rather than a fixture. Watcher health remains independently mandatory. -The away delivery repair line names the concrete restart, `bin/fm-afk-launch.sh start`, and never tells an away session to arm a session delivery wait. +The away delivery repair line names the concrete daemon relaunch the `/afk` skill prescribes for the resolved harness, and never tells an away session to arm a session delivery wait. +A harness with a native tracked-background tool (`claude`, `grok`) is sent down the no-separate-terminal path: `bin/fm-afk-launch.sh start-native`, then `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that tool, with `bin/fm-afk-launch.sh stop` as the rollback. +Every other harness gets the terminal-backed `bin/fm-afk-launch.sh start`. +The away branch is deliberately never prefixed with the drain instruction, because the away daemon reads the durable queue through its own cursor and the session must not drain it. If the away daemon dies during an unattended stretch, the next attempted turn end forces one maintenance continuation that directs the agent to restore away delivery. The hook loop guard then permits that turn to stop, so daemon death cannot create an autonomous endless continuation loop. @@ -208,7 +212,7 @@ Firstmate deliberately does not track `.claude/settings.local.json` anywhere, an ## Tests -`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, dead and healthy away-pusher states with queued wakes, queued wakes with no `state/*.meta` record left, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. +`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, dead and healthy away-pusher states with queued wakes, queued wakes with no `state/*.meta` record left, the drain-first delivery repair a queued-wake activation prints, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. The per-task crewmate overlay recorded in the 2026-07-25 subsection is pinned from the spawn side by `tests/fm-spawn-dispatch-profile.test.sh` (a repository-tracked `.claude/settings.local.json` survives a claude spawn, the overlay is written under the distinct name and git-excluded, and the launch line carries `--settings` for it) and from the teardown side by `tests/fm-teardown.test.sh` cases (z) through (dd) (a tracked-and-dirty overlay still tears down, that tolerance does not mask other genuine uncommitted work, and a repository-tracked legacy `settings.local.json` is never removed). The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index ebb55b554e..69b0e8e5ca 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -93,7 +93,7 @@ pid_identity_of() { # predicate to that REAL publication, so a drift in either daemon-side write # fails here instead of silently reading a healthy away daemon as dead. test_pusher_healthy_accepts_real_daemon_publication() { - local dir state fakebin pid i live_rc dead_rc published_pid lock_pid lock_identity live_identity + local dir state fakebin pid i live_rc dead_rc exit_rc published_pid lock_pid lock_identity live_identity dir=$(make_supercase pusher-healthy-real-daemon) state="$dir/state" fakebin="$dir/fakebin" @@ -120,10 +120,13 @@ test_pusher_healthy_accepts_real_daemon_publication() { pusher_healthy_status "$state" || live_rc=$? kill -TERM "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true + exit_rc=0 + wait_for_exit "$pid" 50 || exit_rc=$? dead_rc=0 pusher_healthy_status "$state" || dead_rc=$? + [ "$exit_rc" -ne 124 ] || fail "the away daemon did not exit within the bounded wait after SIGTERM" + [ "$published_pid" = "$pid" ] \ || fail "daemon pidfile does not name the running daemon (pidfile '$published_pid', daemon $pid): $(cat "$dir/daemon.err" 2>/dev/null)" [ "$lock_pid" = "$pid" ] || fail "daemon lock pid does not name the running daemon: '$lock_pid'" diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index be563a4ce1..0b9458c4a1 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -61,11 +61,29 @@ test_repair_lines() { assert_contains "$out" "bin/fm-watch-checkpoint.sh --seconds 7" "x-mode codex repair line lost the checkpoint helper" out=$(FM_HOME="$home" "$RENDER" --harness claude --afk 1 --repair-line) - assert_contains "$out" "bin/fm-afk-launch.sh start" "away repair line does not name the concrete away-daemon restart" assert_contains "$out" "no live identity-matched away daemon" "away repair line does not describe the dead-daemon situation" + assert_contains "$out" "bin/fm-afk-launch.sh start-native" "claude away repair line does not use the native no-terminal preparation" + assert_contains "$out" "FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh" "claude away repair line does not name the native daemon entry" + assert_contains "$out" "Claude Code background task" "claude away repair line does not host the daemon in the native background tool" assert_not_contains "$out" "load /afk" "away repair line still described the old delivery-ownership contract" assert_not_contains "$out" "bin/fm-watch-arm.sh" "away repair line leaked the session-stub re-arm command" + out=$(FM_HOME="$home" "$RENDER" --harness grok --afk 1 --repair-line) + assert_contains "$out" "bin/fm-afk-launch.sh start-native" "grok away repair line does not use the native no-terminal preparation" + assert_contains "$out" "Grok tracked background task" "grok away repair line does not host the daemon in the native background tool" + + out=$(FM_HOME="$home" "$RENDER" --harness pi --afk 1 --repair-line) + assert_contains "$out" "bin/fm-afk-launch.sh start" "pi away repair line does not name the terminal-backed launcher" + assert_not_contains "$out" "start-native" "pi has no native background tool and must not be sent down the native path" + assert_not_contains "$out" "fm_watch_arm_pi" "away repair line leaked the session-stub re-arm tool" + + out=$(FM_HOME="$home" "$RENDER" --harness codex --afk 1 --repair-line) + assert_contains "$out" "bin/fm-afk-launch.sh start" "codex away repair line does not name the terminal-backed launcher" + assert_not_contains "$out" "start-native" "codex wake supervision forbids native background hosting" + + out=$(FM_HOME="$home" "$RENDER" --harness claude --afk 1 --queue-pending 1 --repair-line) + assert_not_contains "$out" "After draining queued wakes" "away mode must not tell the session to drain the daemon-owned queue" + out=$(FM_HOME="$home" "$RENDER" --harness opencode --read-only 1 --repair-line) assert_contains "$out" "session holding the fleet lock" "read-only repair line missing" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 381ce595fa..0ece73b8b3 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -22,7 +22,9 @@ fm_git_identity fmtest fmtest@example.invalid REQUIRED_REASON='re-arm wake delivery with bin/fm-watch-arm.sh as its own Claude Code background task' SILENT_REASON='This forced continuation is internal maintenance' -AWAY_REPAIR_REASON='restart it with bin/fm-afk-launch.sh start' +AWAY_REPAIR_REASON='bin/fm-afk-launch.sh start-native' +AWAY_NATIVE_ENTRY='FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own Claude Code background task' +DRAIN_FIRST_REASON='After draining queued wakes, ' # --- PREDICATE: bin/fm-supervision-lib.sh ----------------------------------- @@ -378,7 +380,9 @@ test_hook_afk_blocks_with_dead_pusher_and_queued_wakes() { assert_contains "$out" "Away wake delivery missing" "away hook did not identify the dead pusher" assert_not_contains "$out" "Watcher daemon down" "away hook falsely reported the healthy watcher down" assert_contains "$out" "$AWAY_REPAIR_REASON" "away repair line did not name the concrete away-daemon restart" + assert_contains "$out" "$AWAY_NATIVE_ENTRY" "away repair line did not host the daemon in this harness's native background tool" assert_not_contains "$out" "load /afk" "away repair line still described the old delivery-ownership contract" + assert_not_contains "$out" "$DRAIN_FIRST_REASON" "away repair line told the session to drain the daemon-owned queue" [ "$queue_lines" -eq 3 ] || fail "away hook changed the queued wakes while checking pusher health" pass "fm-turnend-guard: dead away pusher with queued wakes blocks the turn" } @@ -406,6 +410,25 @@ test_hook_afk_blocks_with_queued_wakes_and_no_meta() { pass "fm-turnend-guard: queued wakes with no state/*.meta keep the guard active for a dead away pusher" } +test_hook_queued_wakes_repair_names_the_drain() { + local dir pid identity out status + dir=$(make_primary_dir "$TMP_ROOT/hook-queued-no-meta-session") + printf '1\t1\tsignal\ttask1.status\tdone: one\n' > "$dir/state/.wake-queue" + sleep 60 & + pid=$! + identity=$(watcher_identity "$dir" "$pid") || fail "could not identify live watcher holder" + record_watcher_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 2 "$status" "hook must block on queued wakes with a healthy watcher and no armed stub" + assert_contains "$out" "Wake delivery missing" "queued-wake block did not identify the missing delivery half" + assert_contains "$out" "$DRAIN_FIRST_REASON" "delivery repair did not say the queued wakes must be drained before re-arming" + assert_contains "$out" "$REQUIRED_REASON" "delivery repair lost the harness re-arm instruction" + pass "fm-turnend-guard: a queued-wake activation names the drain in the delivery repair line" +} + test_hook_afk_silent_with_healthy_pusher() { local dir watcher_pid watcher_identity pusher_pid pusher_identity out status queue_lines dir=$(make_primary_dir "$TMP_ROOT/hook-afk-healthy-pusher") @@ -1150,6 +1173,7 @@ test_hook_silent_with_live_lock_and_fresh_beacon test_hook_blocks_with_live_daemon_but_no_stub test_hook_afk_blocks_with_dead_pusher_and_queued_wakes test_hook_afk_blocks_with_queued_wakes_and_no_meta +test_hook_queued_wakes_repair_names_the_drain test_hook_afk_silent_with_healthy_pusher test_hook_blocks_with_live_lock_and_stale_beacon test_hook_blocks_when_unhealthy_in_primary From c91eb72bc618011f7742a17d4ac0d2e6c11315dc Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:12:54 +0000 Subject: [PATCH 4/6] no-mistakes(review): warn that away rollback exits away mode --- bin/fm-supervision-instructions.sh | 19 +++++++++---------- docs/turnend-guard.md | 3 ++- tests/fm-supervision-instructions.test.sh | 2 ++ tests/fm-turnend-guard.test.sh | 3 +++ 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 98ec59a1e0..a0e8380123 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -110,18 +110,17 @@ render_snippet() { # job, and the terminal-backed launcher everywhere else. Draining stays out of # it: the away daemon reads the durable queue through its own cursor. away_repair_line() { - local relaunch + local relaunch native_tool case "$HARNESS" in - claude) - relaunch='prepare the lifecycle with bin/fm-afk-launch.sh start-native, then run FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own Claude Code background task (never shell &), rolling the preparation back with bin/fm-afk-launch.sh stop if that native launch fails' - ;; - grok) - relaunch='prepare the lifecycle with bin/fm-afk-launch.sh start-native, then run FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own Grok tracked background task (never shell &), rolling the preparation back with bin/fm-afk-launch.sh stop if that native launch fails' - ;; - *) - relaunch='restart its non-visible tracked terminal with bin/fm-afk-launch.sh start' - ;; + claude) native_tool='Claude Code background task' ;; + grok) native_tool='Grok tracked background task' ;; + *) native_tool='' ;; esac + if [ -n "$native_tool" ]; then + relaunch="prepare the lifecycle with bin/fm-afk-launch.sh start-native, then run FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own $native_tool (never shell &); if that native launch fails, roll the preparation back with bin/fm-afk-launch.sh stop, which EXITS away mode by clearing state/.afk and therefore must be followed immediately by a fresh away entry so the captain is not left unattended without it" + else + relaunch='restart its non-visible tracked terminal with bin/fm-afk-launch.sh start' + fi printf '%s%s%s\n' \ 'Away mode owns wake delivery and no live identity-matched away daemon is reading the durable queue: ' \ "$relaunch" \ diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 85125478a7..d735d760a4 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -45,7 +45,8 @@ The away flag alone does not satisfy the delivery half. Those are exactly the three files `bin/fm-supervise-daemon.sh` publishes at startup, and `tests/fm-daemon.test.sh` pins the predicate against that real publication rather than a fixture. Watcher health remains independently mandatory. The away delivery repair line names the concrete daemon relaunch the `/afk` skill prescribes for the resolved harness, and never tells an away session to arm a session delivery wait. -A harness with a native tracked-background tool (`claude`, `grok`) is sent down the no-separate-terminal path: `bin/fm-afk-launch.sh start-native`, then `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that tool, with `bin/fm-afk-launch.sh stop` as the rollback. +A harness with a native tracked-background tool (`claude`, `grok`) is sent down the no-separate-terminal path: `bin/fm-afk-launch.sh start-native`, then `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that tool, with `bin/fm-afk-launch.sh stop` as the rollback when that native launch fails. +Because the repair runs mid-away-session rather than at a fresh away entry, the line states in place that `stop` exits away mode by clearing `state/.afk` and must be followed immediately by a fresh away entry; that consequence is never left implicit. Every other harness gets the terminal-backed `bin/fm-afk-launch.sh start`. The away branch is deliberately never prefixed with the drain instruction, because the away daemon reads the durable queue through its own cursor and the session must not drain it. diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index 0b9458c4a1..cbcb3cd586 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -65,6 +65,8 @@ test_repair_lines() { assert_contains "$out" "bin/fm-afk-launch.sh start-native" "claude away repair line does not use the native no-terminal preparation" assert_contains "$out" "FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh" "claude away repair line does not name the native daemon entry" assert_contains "$out" "Claude Code background task" "claude away repair line does not host the daemon in the native background tool" + assert_contains "$out" "bin/fm-afk-launch.sh stop, which EXITS away mode by clearing state/.afk" "away rollback does not state that it leaves away mode" + assert_contains "$out" "followed immediately by a fresh away entry" "away rollback does not require re-entering away mode" assert_not_contains "$out" "load /afk" "away repair line still described the old delivery-ownership contract" assert_not_contains "$out" "bin/fm-watch-arm.sh" "away repair line leaked the session-stub re-arm command" diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 0ece73b8b3..06980b0be8 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -24,6 +24,7 @@ REQUIRED_REASON='re-arm wake delivery with bin/fm-watch-arm.sh as its own Claude SILENT_REASON='This forced continuation is internal maintenance' AWAY_REPAIR_REASON='bin/fm-afk-launch.sh start-native' AWAY_NATIVE_ENTRY='FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own Claude Code background task' +AWAY_STOP_CAVEAT='bin/fm-afk-launch.sh stop, which EXITS away mode' DRAIN_FIRST_REASON='After draining queued wakes, ' # --- PREDICATE: bin/fm-supervision-lib.sh ----------------------------------- @@ -381,6 +382,8 @@ test_hook_afk_blocks_with_dead_pusher_and_queued_wakes() { assert_not_contains "$out" "Watcher daemon down" "away hook falsely reported the healthy watcher down" assert_contains "$out" "$AWAY_REPAIR_REASON" "away repair line did not name the concrete away-daemon restart" assert_contains "$out" "$AWAY_NATIVE_ENTRY" "away repair line did not host the daemon in this harness's native background tool" + assert_contains "$out" "$AWAY_STOP_CAVEAT" "away repair line offered the stop rollback without saying it leaves away mode" + assert_contains "$out" "fresh away entry" "away repair line did not require re-entering away mode after the stop rollback" assert_not_contains "$out" "load /afk" "away repair line still described the old delivery-ownership contract" assert_not_contains "$out" "$DRAIN_FIRST_REASON" "away repair line told the session to drain the daemon-owned queue" [ "$queue_lines" -eq 3 ] || fail "away hook changed the queued wakes while checking pusher health" From e37d9feb4da3b450547994457955c733e67bea8e Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:18:32 +0000 Subject: [PATCH 5/6] no-mistakes(review): stop telling away turns to drain the queue --- bin/fm-turnend-guard.sh | 6 +++++- docs/turnend-guard.md | 4 +++- tests/fm-turnend-guard.test.sh | 19 ++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 7568ac2666..d9c0f31ab2 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -131,7 +131,11 @@ rule='━━━━━━━━━━━━━━━━━━━━━━━━ fi printf '● Delivery repair: %s\n' "$DELIVERY_REASON" fi - printf '● This forced continuation is internal maintenance; after draining and restoring delivery, end silently unless a queued wake is captain-relevant under AGENTS.md section 9.\n' + if [ "$afk" -eq 1 ]; then + printf '● This forced continuation is internal maintenance; after restoring away delivery, end silently unless a queued wake is captain-relevant under AGENTS.md section 9.\n' + else + printf '● This forced continuation is internal maintenance; after draining and restoring delivery, end silently unless a queued wake is captain-relevant under AGENTS.md section 9.\n' + fi printf '●%s\n' "$rule" } >&2 exit 2 diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index d735d760a4..e0a6d37cc4 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -49,6 +49,8 @@ A harness with a native tracked-background tool (`claude`, `grok`) is sent down Because the repair runs mid-away-session rather than at a fresh away entry, the line states in place that `stop` exits away mode by clearing `state/.afk` and must be followed immediately by a fresh away entry; that consequence is never left implicit. Every other harness gets the terminal-backed `bin/fm-afk-launch.sh start`. The away branch is deliberately never prefixed with the drain instruction, because the away daemon reads the durable queue through its own cursor and the session must not drain it. +For the same reason the banner's closing maintenance line also branches on away mode: an away turn is told to restore away delivery and end silently, while a normal session keeps the drain-and-restore wording. +An away banner that named a drain would be a data-loss instruction, since `bin/fm-wake-drain.sh` would consume records the daemon's cursor has not read yet; `bin/fm-afk-return.sh` owns that drain at return time instead. If the away daemon dies during an unattended stretch, the next attempted turn end forces one maintenance continuation that directs the agent to restore away delivery. The hook loop guard then permits that turn to stop, so daemon death cannot create an autonomous endless continuation loop. @@ -213,7 +215,7 @@ Firstmate deliberately does not track `.claude/settings.local.json` anywhere, an ## Tests -`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, dead and healthy away-pusher states with queued wakes, queued wakes with no `state/*.meta` record left, the drain-first delivery repair a queued-wake activation prints, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. +`tests/fm-turnend-guard.test.sh` covers the split daemon-and-delivery predicate, independent repair lines, delivery-stub pid and identity matching, primary scoping, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, dead and healthy away-pusher states with queued wakes, queued wakes with no `state/*.meta` record left, the drain-first delivery repair a queued-wake activation prints, both closing-banner branches (away restores delivery and never names a drain anywhere in the banner; a normal session keeps drain-and-restore), fail-open behavior without `jq`, tracked hook registration for all five harnesses, and passive-adapter loop guards. The per-task crewmate overlay recorded in the 2026-07-25 subsection is pinned from the spawn side by `tests/fm-spawn-dispatch-profile.test.sh` (a repository-tracked `.claude/settings.local.json` survives a claude spawn, the overlay is written under the distinct name and git-excluded, and the launch line carries `--settings` for it) and from the teardown side by `tests/fm-teardown.test.sh` cases (z) through (dd) (a tracked-and-dirty overlay still tears down, that tolerance does not mask other genuine uncommitted work, and a repository-tracked legacy `settings.local.json` is never removed). The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 06980b0be8..57fc31bae4 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -26,6 +26,17 @@ AWAY_REPAIR_REASON='bin/fm-afk-launch.sh start-native' AWAY_NATIVE_ENTRY='FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh as its own Claude Code background task' AWAY_STOP_CAVEAT='bin/fm-afk-launch.sh stop, which EXITS away mode' DRAIN_FIRST_REASON='After draining queued wakes, ' +AWAY_CLOSING_REASON='This forced continuation is internal maintenance; after restoring away delivery, end silently unless a queued wake is captain-relevant under AGENTS.md section 9.' +SESSION_CLOSING_REASON='This forced continuation is internal maintenance; after draining and restoring delivery, end silently unless a queued wake is captain-relevant under AGENTS.md section 9.' + +# The away daemon reads state/.wake-queue through its own cursor, so any drain +# wording anywhere in an away banner is a data-loss instruction: fm-wake-drain.sh +# would consume records the daemon has not read yet. +assert_away_output_never_says_drain() { + local out=$1 context=$2 + assert_not_contains "$out" "drain" "$context" + assert_not_contains "$out" "Drain" "$context" +} # --- PREDICATE: bin/fm-supervision-lib.sh ----------------------------------- @@ -385,7 +396,9 @@ test_hook_afk_blocks_with_dead_pusher_and_queued_wakes() { assert_contains "$out" "$AWAY_STOP_CAVEAT" "away repair line offered the stop rollback without saying it leaves away mode" assert_contains "$out" "fresh away entry" "away repair line did not require re-entering away mode after the stop rollback" assert_not_contains "$out" "load /afk" "away repair line still described the old delivery-ownership contract" - assert_not_contains "$out" "$DRAIN_FIRST_REASON" "away repair line told the session to drain the daemon-owned queue" + assert_contains "$out" "$AWAY_CLOSING_REASON" "away banner did not close with the restore-away-delivery instruction" + assert_not_contains "$out" "$SESSION_CLOSING_REASON" "away banner closed with the session drain-and-restore wording" + assert_away_output_never_says_drain "$out" "away banner told the session to drain the daemon-owned queue" [ "$queue_lines" -eq 3 ] || fail "away hook changed the queued wakes while checking pusher health" pass "fm-turnend-guard: dead away pusher with queued wakes blocks the turn" } @@ -409,6 +422,8 @@ test_hook_afk_blocks_with_queued_wakes_and_no_meta() { assert_contains "$out" "$AWAY_REPAIR_REASON" "queued-wake block did not name the concrete away-daemon restart" assert_contains "$out" "Watcher daemon down: queued wake(s) pending" "banner did not name the queued wakes as the protected work" assert_not_contains "$out" "0 task(s) in flight" "banner claimed an in-flight count it does not have" + assert_contains "$out" "$AWAY_CLOSING_REASON" "away banner did not close with the restore-away-delivery instruction" + assert_away_output_never_says_drain "$out" "away banner told the session to drain the daemon-owned queue" [ "$queue_lines" -eq 2 ] || fail "away hook changed the queued wakes while checking pusher health" pass "fm-turnend-guard: queued wakes with no state/*.meta keep the guard active for a dead away pusher" } @@ -429,6 +444,8 @@ test_hook_queued_wakes_repair_names_the_drain() { assert_contains "$out" "Wake delivery missing" "queued-wake block did not identify the missing delivery half" assert_contains "$out" "$DRAIN_FIRST_REASON" "delivery repair did not say the queued wakes must be drained before re-arming" assert_contains "$out" "$REQUIRED_REASON" "delivery repair lost the harness re-arm instruction" + assert_contains "$out" "$SESSION_CLOSING_REASON" "session banner lost the drain-and-restore closing instruction" + assert_not_contains "$out" "$AWAY_CLOSING_REASON" "session banner closed with the away-only wording" pass "fm-turnend-guard: a queued-wake activation names the drain in the delivery repair line" } From 178cd60e4eded0c6b945e3b9c9cc56cbb919acb3 Mon Sep 17 00:00:00 2001 From: Freudator86 <94322668+Freudator86@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:26:46 +0000 Subject: [PATCH 6/6] no-mistakes(document): sync architecture and scripts docs with away-pusher guard --- docs/architecture.md | 2 +- docs/scripts.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9966fb2648..383c999743 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,7 +66,7 @@ Optional direct Telegram receive is armed separately from watcher supervision wh A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, if tasks are in flight and the daemon lock plus beacon are unhealthy, if the current session's delivery-stub identity is absent, or if queued wakes are waiting to be drained. The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the stale-watcher banner/reminder policy so repeated guarded commands stay noisy without reprinting the full watcher-down banner in the same episode. -On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and either the daemon is unhealthy or the session delivery stub is unarmed, direct Stop hooks block and passive turn-end hooks force one bounded follow-up. +On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight or a durable queued wake is still unread, and either the daemon is unhealthy or wake delivery is unarmed (the session delivery stub normally, a live identity-matched away daemon while `state/.afk` is present), direct Stop hooks block and passive turn-end hooks force one bounded follow-up. The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the external watcher keeps enqueuing and the away daemon reads new queue records without draining the queue. diff --git a/docs/scripts.md b/docs/scripts.md index c80b41240e..a5898d90b5 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -96,7 +96,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | | `fm-currency-base-lib.sh` | Shared resolution and validation of the two upstream-check comparison bases | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes, emit bounded best-effort status-event annotations, then assert watcher liveness | -| `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher identity/health helpers | +| `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher/away-daemon identity/health helpers | | `fm-classify-lib.sh` | Shared captain-relevant and declared-external-wait wake classification vocabulary | | `fm-send.sh` | Send one verified literal line or supported key through the target's recorded backend | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, composer capture, and verified submit |