diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 058a1947844..028df87acd7 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -24,17 +24,22 @@ batched digest rather than per-wake injections. The flag survives a firstmate restart, so recovery re-enters afk when it is present. 2. **Ensure the sub-supervisor daemon is running as a tracked background process.** - Its hosting differs by harness. + Its hosting differs by harness AND backend. Pick the right path: - - **Harness WITH a native in-pane tracked-background tool** (e.g. claude's - background bash, grok's background tool): first run + - **Claude on the herdr backend: always use the terminal-backed path below, never `start-native`.** + A Claude Code background shell hosting the daemon in the captain's own pane leaves a `· 1 shell ·` token in that pane's rendered footer for as long as the daemon lives. + Herdr's own claude agent-detection ruleset maps that exact token to `agent_status = "working"` at a priority that beats the idle rule, so the away-mode busy guard reads the captain's pane as permanently busy and can never deliver an escalation into it for the life of the session - proven in `data/firstmate-afk-daemon-wedged-investigation/report.md` (2026-08-26), which found this in every claude+herdr away run since 2026-08-19. + Do not "fix" this by teaching the busy guard to subtract the daemon's own footer contribution; that couples firstmate to a private, versioned herdr ruleset that already changes without notice. + `bin/fm-afk-launch.sh start-native` enforces this rule itself: on claude+herdr it refuses before writing any lifecycle state, so a refusal there is the guard working and there is nothing to roll back - re-enter with `bin/fm-afk-launch.sh start`. + - **Harness WITH a native in-pane tracked-background tool, on any OTHER combination** (e.g. claude's + background bash on tmux, grok's background tool): first run `bin/fm-afk-launch.sh start-native`, then run `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. This is a deliberate no-separate-terminal exception because the harness-hosted job creates no terminal or layout mutation, and a shell launcher cannot invoke a harness-native background tool. The launcher still owns lifecycle state and records the no-terminal mode, while the daemon inherits and auto-discovers the captain pane. If the native launch fails, run `bin/fm-afk-launch.sh stop` to roll back the prepared lifecycle. Do not wrap it in `nohup ... &` (Codex/herdr can reap fire-and-forget shell children after a tool call returns). - - **Harness WITHOUT one** (e.g. pi): run `bin/fm-afk-launch.sh start`. It is + - **Harness WITHOUT a native in-pane tool (e.g. pi), and claude on herdr per above**: run `bin/fm-afk-launch.sh start`. It is the single owner of the daemon terminal: it creates a NON-VISIBLE tracked terminal for the current backend (a herdr dedicated `--no-focus` workspace, a detached tmux session), records its exact id, and passes the captain pane diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 5df2a9d9915..efbfad29650 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -6,9 +6,15 @@ # Why this exists (docs/herdr-backend.md "Away-mode daemon terminal launch"): # bin/fm-afk-start.sh execs the supervise daemon in the FOREGROUND of whatever # terminal it is already in. Harnesses with a native in-pane tracked-background -# tool (claude, grok) run it there directly and it is fine. A harness with NO -# native background mechanism (pi) has to manufacture a terminal, and doing that -# by SPLITTING the captain's active pane visibly shrinks it - the regression this +# tool (claude, grok) run it there directly on most backends and it is fine. +# claude on the herdr backend is the proven exception, NOT fine: the in-pane +# background job leaves a footer token that herdr's own claude agent-detection +# ruleset misreads as "working" for the life of the session, so the away-mode +# busy guard can never read that pane idle and the daemon can never deliver an +# escalation into it (data/firstmate-afk-daemon-wedged-investigation/report.md, +# 2026-08-26). A harness with NO native background mechanism (pi), and claude +# on herdr per that exception, has to manufacture a terminal, and doing that by +# SPLITTING the captain's active pane visibly shrinks it - the regression this # script fixes. Instead this creates a non-visible tracked terminal (a herdr tab/ # workspace with --no-focus, or a detached tmux session) that never touches the # captain's active tab, and NEVER uses shell `&` (which herdr/codex can reap). @@ -29,6 +35,8 @@ # fm-afk-launch.sh start-native # Prepare lifecycle state for a harness-native # background job and record that no terminal exists. +# Refuses on claude+herdr (the exception above) and +# points at `start`, before writing any state. # fm-afk-launch.sh stop Correct-ordered exit: SIGTERM the daemon so its # cleanup flushes WHILE state/.afk is still present, # wait for it, close the recorded terminal by exact @@ -527,8 +535,30 @@ fm_afk_launch_start() { return "$result" } +# The claude+herdr exception, enforced rather than merely documented. A claude +# in-pane background job renders a shell token in the captain pane's footer for +# as long as the daemon lives; herdr's own claude agent-detection ruleset maps +# that token to "working" above its idle rule, so the away-mode busy guard reads +# the captain pane busy forever and no escalation is ever delivered. Refuse this +# ONE combination and name the terminal-backed path; every other harness/backend +# pair keeps the native exception. +fm_afk_launch_native_refused() { + local harness backend + backend=$(discover_supervisor_backend) || true + [ "$backend" = herdr ] || return 1 + harness=$("$FM_AFK_LAUNCH_DIR/fm-harness.sh" 2>/dev/null) || harness=unknown + [ "$harness" = claude ] || return 1 + return 0 +} + fm_afk_launch_start_native() { local backup artifact had_afk=0 result=0 + # Refuse BEFORE touching lifecycle state, so a refusal leaves nothing to undo. + if fm_afk_launch_native_refused; then + fm_afk_launch_log "refusing start-native on claude+herdr: a claude background shell renders in the captain pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing" + fm_afk_launch_log "use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)" + return 1 + fi mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode" diff --git a/bin/fm-afk-start.sh b/bin/fm-afk-start.sh index e86c54f170a..c8268b12063 100755 --- a/bin/fm-afk-start.sh +++ b/bin/fm-afk-start.sh @@ -20,13 +20,19 @@ # This is the COMMON daemon entry for every backend. HOW it becomes a tracked # background process differs by harness/backend and is owned elsewhere: # - Harnesses with a native in-pane tracked-background tool (e.g. claude, grok) -# run this directly via that tool, so the daemon inherits the captain pane's -# env and auto-discovers it. -# - Harnesses with NO native background mechanism (e.g. pi) run this THROUGH -# bin/fm-afk-launch.sh, which creates a non-visible tracked terminal per -# backend (herdr tab/workspace, tmux detached session) and passes the -# captain pane in as FM_SUPERVISOR_TARGET so injection targets it, not the -# daemon's own new pane. +# run this directly via that tool on most backends, so the daemon inherits +# the captain pane's env and auto-discovers it. EXCEPTION: claude on the +# herdr backend must NOT use this path - the in-pane background job leaves +# a footer token that herdr's own claude agent-detection ruleset misreads as +# "working" for the life of the session, structurally wedging the away-mode +# busy guard (data/firstmate-afk-daemon-wedged-investigation/report.md, +# 2026-08-26; see .agents/skills/afk/SKILL.md for the routing rule). +# - Harnesses with NO native background mechanism (e.g. pi), and claude on +# herdr per the exception above, run this THROUGH bin/fm-afk-launch.sh, +# which creates a non-visible tracked terminal per backend (herdr tab/ +# workspace, tmux detached session) and passes the captain pane in as +# FM_SUPERVISOR_TARGET so injection targets it, not the daemon's own new +# pane. # Do not wrap this in `nohup ... &`: Codex/herdr can reap fire-and-forget shell # children after the tool call returns, while a tracked background terminal stays # attached and has a real lifecycle. @@ -41,11 +47,87 @@ FM_AFK_DAEMON="$FM_AFK_START_DIR/fm-supervise-daemon.sh" # shellcheck source=bin/fm-wake-lib.sh . "$FM_AFK_START_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-supervisor-target-lib.sh +. "$FM_AFK_START_DIR/fm-supervisor-target-lib.sh" fm_afk_start_usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' } +# The claude+herdr exception, enforced here too: bin/fm-afk-launch.sh's own +# start-native guard only covers entry THROUGH the launcher. This script is +# also a documented direct entry point (the second half of the native two-step, +# and a bare direct call), so the same wedge is reachable by skipping the +# launcher entirely. +# +# The hazard is PHYSICAL: the daemon hosting itself in the very pane it will +# inject escalations into (self-injection). On claude+herdr that pane's footer +# then carries the background-shell token for the life of the session, herdr's +# claude detection reads it as "the agent is working", and every escalation +# defers forever. So the signal is a same-pane comparison, not the presence of +# any environment knob: fm_afk_start_own_pane resolves where THIS process is +# actually running (raw $TMUX_PANE / $HERDR_ENV+$HERDR_PANE_ID, deliberately +# ignoring the FM_SUPERVISOR_TARGET override), while discover_supervisor_target +# resolves where injection will actually land (override first, exactly as +# inject_msg does). The launcher's terminal-backed path stays permitted because +# it runs in its OWN separate pane and targets the captain's - different panes, +# no self-injection. +# +# fm_afk_start_own_backend / fm_afk_start_own_pane: this process's RAW identity. +# Distinct from discover_supervisor_backend/discover_supervisor_target on +# purpose - those answer "where do escalations go", which an operator may +# override; these answer "where am I", which nothing may override. Both return +# non-zero when this process is in no pane at all. +fm_afk_start_own_backend() { + if [ -n "${TMUX_PANE:-}" ]; then + printf 'tmux' + return 0 + fi + if [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then + printf 'herdr' + return 0 + fi + return 1 +} + +fm_afk_start_own_pane() { + if [ -n "${TMUX_PANE:-}" ]; then + printf '%s' "$TMUX_PANE" + return 0 + fi + if [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then + printf '%s:%s' "${HERDR_SESSION:-default}" "$HERDR_PANE_ID" + return 0 + fi + return 1 +} + +fm_afk_start_native_refused() { + local harness own_backend own_pane target + harness=$("$FM_AFK_START_DIR/fm-harness.sh" 2>/dev/null) || harness=unknown + [ "$harness" = claude ] || return 1 + + # Resolve backend FIRST and narrow the "cannot check myself" diagnostic to + # when it is actually true: herdr provably absent (no TMUX_PANE, no + # HERDR_ENV+HERDR_PANE_ID) or resolved to a different backend is not + # ambiguity, it is proof there is nothing to warn about - permit silently. + own_backend=$(fm_afk_start_own_backend) || return 1 + [ "$own_backend" = herdr ] || return 1 + + own_pane=$(fm_afk_start_own_pane) || { + echo "afk: resolved this process's own backend as herdr but could not resolve its own pane, so the claude+herdr self-injection guard cannot check itself; permitting this start rather than blocking on unknown state" >&2 + return 1 + } + + target=$(discover_supervisor_target) || { + echo "afk: cannot resolve the escalation injection target, so the claude+herdr self-injection guard cannot check itself; permitting this start rather than blocking on unknown state" >&2 + return 1 + } + + [ "$own_pane" = "$target" ] || return 1 + return 0 +} + # fm_afk_clear_stale_artifacts: on a FRESH away-session entry (the daemon is not # already running), drop the previous away session's leftover escalation-delivery # artifacts so they cannot surface as stale escalations under the new session. @@ -130,6 +212,17 @@ fm_afk_flag_write() { # return 1 } +# Write (or verify) the lifecycle flag on the path that has decided to proceed. +# Shared by the refresh and fresh branches of fm_afk_start_main so neither +# writes it before that decision is made. +fm_afk_start_ensure_flag() { + if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then + [ -f "$FM_AFK_STATE/.afk" ] || { echo "afk: launcher-prepared state is missing" >&2; return 1; } + else + fm_afk_flag_write "$FM_AFK_STATE" || { echo "afk: failed to write away-mode flag" >&2; return 1; } + fi +} + fm_afk_start_main() { case "${1:-}" in '' ) ;; @@ -138,19 +231,26 @@ fm_afk_start_main() { esac mkdir -p "$FM_AFK_STATE" - if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then - [ -f "$FM_AFK_STATE/.afk" ] || { echo "afk: launcher-prepared state is missing" >&2; return 1; } - else - fm_afk_flag_write "$FM_AFK_STATE" || { echo "afk: failed to write away-mode flag" >&2; return 1; } - fi local pid pid=$(daemon_lock_pid 2>/dev/null || true) if daemon_lock_held_by_live_daemon; then + fm_afk_start_ensure_flag || return 1 echo "afk: daemon already running pid=$pid" return 0 fi + # Only a genuine FRESH start reaches here (a refresh of an already-live, + # already-safely-hosted daemon returned above, untouched). Decide BEFORE + # writing any lifecycle state, so a refusal leaves nothing to roll back. + if fm_afk_start_native_refused; then + echo "afk: refusing to host the away daemon in the same pane it injects into on claude+herdr: a claude background shell renders in that pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing" >&2 + echo "afk: use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)" >&2 + return 1 + fi + + fm_afk_start_ensure_flag || return 1 + if fm_pid_alive "$pid" && [ -n "$pid" ]; then fm_lock_remove_path "$FM_AFK_LOCK" 2>/dev/null || true fi diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index f5a3f528d5f..4c1c2cc17c5 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -291,8 +291,11 @@ It refuses Zellij, Orca, and cmux as supervisor backends rather than applying th For Herdr, target existence, native state, capture, composer state, and verified submit all route through the shared backend dispatcher and the explicit named-session CLI owner. The pane-independent max-defer alert is configured in [`wedge-alarm.md`](wedge-alarm.md). -Harnesses with native tracked background execution can run the daemon in their terminal. -Pi has no such mechanism. +Harnesses with native tracked background execution can run the daemon in their terminal, with one proven exception: Claude must not host the away daemon this way on Herdr. +Claude renders a live background shell in its own pane footer, and Herdr's Claude agent-detection ruleset reads that footer token as the agent working, at a priority that beats its idle rule. +The busy guard trusts that native state first, so it reads the daemon's own presence as the captain pane being busy and can never deliver an escalation into it for the life of the session (`data/firstmate-afk-daemon-wedged-investigation/report.md`, 2026-08-26). +Pi has no native background mechanism at all, and Claude on Herdr is routed the same way for the reason above. +`bin/fm-afk-launch.sh start-native` enforces that routing rather than relying on the caller having read it: it refuses the Claude-on-Herdr pair before writing any lifecycle state and names `start` instead, covered by `tests/fm-afk-launch.test.sh`. `bin/fm-afk-launch.sh` therefore creates a dedicated unfocused Herdr workspace, runs the daemon there with an explicit supervisor target and backend, records the exact daemon pane, and closes only that pane on stop. It never splits the captain's active tab and never uses shell `&`. Recovery reconciles only the recorded exact id. diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 6d0c7bd9d1a..3207ab3e0ee 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -40,6 +40,14 @@ GLOBAL_CLEANUP() { } trap GLOBAL_CLEANUP EXIT +# bin/fm-afk-start.sh refuses a start that would host the away daemon in the very +# pane it injects into. Every test below that runs its main is about something +# else, so they pin a neutral identity - no harness marker, no pane manager - +# instead of inheriting the test runner's real captain session. +START_GUARD_CLEAN_ENV=(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u PI_CODING_AGENT + -u GROK_AGENT -u TMUX_PANE -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND + -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SESSION) + # --------------------------------------------------------------------------- # UNIT 1: fm_afk_clear_stale_artifacts removes exactly the three stale artifacts. # --------------------------------------------------------------------------- @@ -136,7 +144,7 @@ unit_fresh_vs_refresh() { mkdir -p "$lock" printf '%s' "$sleep_pid" > "$lock/pid" ( . "$ROOT/bin/fm-wake-lib.sh"; fm_pid_identity "$sleep_pid" > "$lock/pid-identity" 2>/dev/null ) || true - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$START" >/dev/null 2>&1 + "${START_GUARD_CLEAN_ENV[@]}" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$START" >/dev/null 2>&1 if [ -e "$st/state/.subsuper-escalations" ] && [ -e "$st/state/.subsuper-inject-wedged" ]; then pass "refresh: daemon already alive - stale artifacts preserved (current session's buffer kept)" else @@ -488,7 +496,8 @@ unit_native_lifecycle() { st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native.XXXXXX") mkdir -p "$st/state" : > "$st/state/.subsuper-escalations" - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native >/dev/null 2>&1 \ + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_BACKEND=tmux \ + "$LAUNCH" start-native >/dev/null 2>&1 \ && [ "$(cut -f1 "$st/state/.afk-daemon-terminal")" = none ] \ && [ -e "$st/state/.afk" ] \ && [ ! -e "$st/state/.subsuper-escalations" ]; then @@ -505,13 +514,201 @@ unit_native_lifecycle() { rm -rf "$st" } +# Regression for the 95-minute wedged away session: claude hosting the daemon in +# its own pane on herdr leaves a footer shell token that herdr reports as +# "working", so the busy guard never sees the captain pane idle. The launcher +# must refuse exactly that pair, write no lifecycle state when it does, and keep +# permitting every neighbouring pair. +unit_native_refuses_claude_on_herdr() { + local st out refused=0 + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-guard.XXXXXX") + mkdir -p "$st/state" + + out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT \ + CLAUDECODE=1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=herdr \ + "$LAUNCH" start-native 2>&1) || refused=1 + if [ "$refused" -eq 1 ] && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ]; then + pass "native guard: claude+herdr is refused with no lifecycle state written" + else + fail "native guard: claude+herdr native launch was accepted" + fi + case "$out" in + *'bin/fm-afk-launch.sh start'*) pass "native guard: refusal names the terminal-backed path" ;; + *) fail "native guard: refusal did not name the terminal-backed path" ;; + esac + + rm -rf "$st" + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-guard.XXXXXX") + mkdir -p "$st/state" + if env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT \ + CLAUDECODE=1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=tmux \ + "$LAUNCH" start-native >/dev/null 2>&1 \ + && [ "$(cut -f1 "$st/state/.afk-daemon-terminal")" = none ]; then + pass "native guard: claude on a non-herdr backend is still permitted" + else + fail "native guard: claude on a non-herdr backend was refused" + fi + + rm -rf "$st" + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-guard.XXXXXX") + mkdir -p "$st/state" + if env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u GROK_AGENT \ + PI_CODING_AGENT=true FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=herdr \ + "$LAUNCH" start-native >/dev/null 2>&1 \ + && [ "$(cut -f1 "$st/state/.afk-daemon-terminal")" = none ]; then + pass "native guard: a non-claude harness on herdr is still permitted" + else + fail "native guard: a non-claude harness on herdr was refused" + fi + rm -rf "$st" +} + +# --------------------------------------------------------------------------- +# UNIT: bin/fm-afk-start.sh is ALSO a documented direct entry point (the +# native two-step's second half, and a bare direct call), so the launcher's +# own start-native guard above does not cover it - the same wedge is reachable +# by skipping the launcher entirely. The signal is PHYSICAL: refuse only when +# this process's OWN pane (raw $TMUX_PANE / $HERDR_ENV+$HERDR_PANE_ID) is the +# very pane escalations will be injected into (discover_supervisor_target, +# which honours the FM_SUPERVISOR_TARGET override). Same pane on claude+herdr +# means the daemon hosts itself in the pane it drives - the wedge. Different +# panes (the launcher's own separate terminal targeting the captain's) stay +# permitted, and so does an operator override pointing somewhere else: the +# comparison is where-am-I vs where-does-injection-land, never the mere +# presence of a documented env knob. +# --------------------------------------------------------------------------- + +# Run fm_afk_start_main in a child shell with a harmless daemon stand-in, so a +# PERMITTED start is observable as its own announcement rather than by execing +# the real daemon. Prints combined output; returns the real exit status. +start_guard_run() { # ... + local st=$1; shift + # shellcheck disable=SC2016 # $1 is bash -c's own positional param. + "${START_GUARD_CLEAN_ENV[@]}" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$@" bash -c ' + . "$1" + FM_AFK_DAEMON=/bin/true + fm_afk_start_main + ' _ "$START" 2>&1 +} + +unit_start_refuses_self_injecting_claude_on_herdr() { + local st out status + + # (1) claude+herdr, own pane IS the injection target -> refused, and no + # lifecycle state left behind. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + status=0 + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1) || status=$? + if [ "$status" -ne 0 ] && [ ! -e "$st/state/.afk" ]; then + pass "start guard: claude+herdr self-injection is refused with no lifecycle state left behind" + else + fail "start guard: claude+herdr self-injection was accepted ($out)" + fi + case "$out" in + *'bin/fm-afk-launch.sh start'*) pass "start guard: refusal names the terminal-backed path" ;; + *) fail "start guard: refusal did not name the terminal-backed path ($out)" ;; + esac + rm -rf "$st" + + # (2) claude+herdr, launcher-placed separate terminal: own pane differs from + # the captain pane it injects into -> permitted (not a blanket block). + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=daemon-pane \ + FM_SUPERVISOR_TARGET=default:captain-pane FM_SUPERVISOR_BACKEND=herdr) + case "$out" in + *'starting supervise daemon'*) pass "start guard: claude+herdr in the launcher's own separate pane is permitted" ;; + *) fail "start guard: launcher-hosted claude+herdr entry was refused ($out)" ;; + esac + rm -rf "$st" + + # (3) claude+herdr with a hand-set FM_SUPERVISOR_TARGET pointing at a pane + # this process is NOT running in -> permitted: no self-injection. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 \ + FM_SUPERVISOR_TARGET=default:some-other-pane) + case "$out" in + *'starting supervise daemon'*) pass "start guard: an override naming a different pane is permitted" ;; + *) fail "start guard: an override naming a different pane was refused ($out)" ;; + esac + rm -rf "$st" + + # (3b) the same documented override pointed AT this process's own pane is + # still refused: the comparison is physical, not override-presence-based. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + status=0 + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 \ + FM_SUPERVISOR_TARGET=default:pane-1 FM_SUPERVISOR_BACKEND=herdr) || status=$? + if [ "$status" -ne 0 ] && [ ! -e "$st/state/.afk" ]; then + pass "start guard: an override naming this process's own pane is still refused" + else + fail "start guard: an operator-set override reopened the self-injection wedge ($out)" + fi + rm -rf "$st" + + # (4) a non-claude primary in the same pane -> permitted: only claude's + # footer token wedges herdr's detection. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(start_guard_run "$st" PI_CODING_AGENT=true HERDR_ENV=1 HERDR_PANE_ID=pane-1) + case "$out" in + *'starting supervise daemon'*) pass "start guard: a non-claude harness in the same pane is permitted" ;; + *) fail "start guard: a non-claude harness in the same pane was refused ($out)" ;; + esac + rm -rf "$st" + + # (5) a refresh of an already-live daemon takes the existing refresh path + # untouched: nothing is being started, so nothing is refused. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + status=0 + # shellcheck disable=SC2016 # $1 is bash -c's own positional param. + out=$("${START_GUARD_CLEAN_ENV[@]}" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 \ + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' + . "$1" + FM_AFK_DAEMON=/bin/true + daemon_lock_pid() { echo 4242; } + daemon_lock_held_by_live_daemon() { return 0; } + fm_afk_start_main + ' _ "$START" 2>&1) || status=$? + case "$status:$out" in + 0*'daemon already running pid=4242'*) pass "start guard: a refresh of a live daemon takes the refresh path, not the refusal" ;; + *) fail "start guard: a refresh of a live daemon was refused (status=$status) ($out)" ;; + esac + rm -rf "$st" + + # (6) herdr provably absent (no TMUX_PANE, no HERDR_ENV+HERDR_PANE_ID) is not + # ambiguity - there is nothing the guard could have failed to check, so it + # permits SILENTLY rather than crying wolf on every plain claude terminal. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(start_guard_run "$st" CLAUDECODE=1) + case "$out" in + *'starting supervise daemon'*) pass "start guard: herdr provably absent permits rather than blocks" ;; + *) fail "start guard: herdr provably absent was refused ($out)" ;; + esac + case "$out" in + *"cannot resolve"*) fail "start guard: herdr provably absent still emitted the could-not-check diagnostic ($out)" ;; + *) pass "start guard: herdr provably absent permits without the could-not-check diagnostic" ;; + esac + rm -rf "$st" +} + unit_native_entry_preserves_prepared_state() { local st st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-entry.XXXXXX") mkdir -p "$st/state" : > "$st/state/.afk" : > "$st/state/.subsuper-escalations" - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_AFK_STATE_PREPARED=1 bash -c ' + # shellcheck disable=SC2016 # $1 is bash -c's own positional param. + "${START_GUARD_CLEAN_ENV[@]}" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_AFK_STATE_PREPARED=1 bash -c ' . "$1" FM_AFK_DAEMON=/bin/true fm_afk_start_main @@ -759,7 +956,7 @@ unit_clear_failure_aborts_entry() { st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-clear-fail.XXXXXX") mkdir -p "$st/state" : > "$st/state/.subsuper-escalations" - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_BACKEND=tmux bash -c ' . "$1" fm_afk_launch_reconcile() { return 0; } fm_afk_clear_stale_artifacts() { return 1; } @@ -812,7 +1009,7 @@ unit_flag_write_failure_aborts() { local st st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-flag-fail.XXXXXX") mkdir -p "$st/state" - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_BACKEND=tmux bash -c ' . "$1" fm_afk_launch_flag_write() { return 1; } ! fm_afk_launch_start_native @@ -940,6 +1137,8 @@ unit_readiness_failure_rolls_back_terminal unit_readiness_failure_preserves_unconfirmed_record unit_tmux_absence_distinguishes_probe_failure unit_native_lifecycle +unit_native_refuses_claude_on_herdr +unit_start_refuses_self_injecting_claude_on_herdr unit_native_entry_preserves_prepared_state unit_close_failure_preserves_record unit_record_publication_atomic diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index c345e4f55e2..d5fd613f03d 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -241,7 +241,8 @@ test_away_reentry_refuses_pending_return_gate() { mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" printf 'schema\tfm-afk-return.v1\nphase\tblocked\n' > "$dir/home/state/.afk-return-catchup" set +e - out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$ROOT/bin/fm-afk-launch.sh" start-native 2>&1) + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_SUPERVISOR_BACKEND=tmux \ + "$ROOT/bin/fm-afk-launch.sh" start-native 2>&1) rc=$? set -e [ "$rc" -ne 0 ] || fail "away re-entry succeeded while return catch-up was pending" diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 962f143464d..f0c7f2c3d63 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -12,6 +12,14 @@ set -u DAEMON="$ROOT/bin/fm-supervise-daemon.sh" AFK_START="$ROOT/bin/fm-afk-start.sh" +# fm-afk-start.sh reads the AMBIENT pane and harness environment to decide whether +# a start would host the daemon in the pane it injects into. These tests are about +# lock/pidfile reclamation, not that guard, so pin a neutral identity: no harness +# marker and no pane manager, which the test runner would otherwise inherit from a +# real captain session. +AFK_START_ENV=(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u PI_CODING_AGENT + -u GROK_AGENT -u TMUX_PANE -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SESSION + -u FM_SUPERVISOR_TARGET) # Source the daemon's pure functions once. Its main loop is skipped under sourcing # via a BASH_SOURCE guard, so only classify_*/housekeeping/escalate_*/afk_* and the # pane/submit helpers become defined. @@ -31,7 +39,7 @@ test_afk_start_refuses_when_flag_cannot_be_written() { state="$dir/state" mkdir -p "$state/.afk" - out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) + out=$("${AFK_START_ENV[@]}" FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) status=$? [ "$status" -ne 0 ] || fail "fm-afk-start.sh should fail when state/.afk cannot be written" @@ -46,7 +54,7 @@ test_afk_start_ignores_stale_pidfile_without_lock() { state="$dir/state" printf '%s\n' "$$" > "$state/.supervise-daemon.pid" - out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) + out=$("${AFK_START_ENV[@]}" FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) status=$? [ "$status" -ne 0 ] || fail "fm-afk-start.sh should attempt daemon startup instead of trusting a pidfile-only live pid" @@ -66,7 +74,7 @@ test_afk_start_reclaims_stale_daemon_lock_reused_pid() { printf '%s\n' "$$" > "$lock/pid" printf '%s\n' "stale daemon identity" > "$lock/pid-identity" - out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) + out=$("${AFK_START_ENV[@]}" FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) status=$? [ "$status" -ne 0 ] || fail "fm-afk-start.sh should attempt daemon startup after rejecting a reused-pid lock"