diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 9d153ba6f7b..dfa1b2bf6fc 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -94,6 +94,7 @@ backend (tmux or herdr; see "Auto-discovered supervisor pane" below): - **Primary-pane busy guard** - `pane_is_busy` trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature. This narrow delivery guard never classifies a recorded worker task and never uses a global union of vendor patterns. + The rendered scan has no positional anchor - it can match settled SCROLLBACK text, not only a live spinner footer - so an affirmatively empty composer (proof the turn has ended) short-circuits it first; a genuinely busy pane never reads empty (verified live, 2026-09-01: claude-on-herdr). - **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`pending-unproven`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`. Every other or future verdict defers, including an unreadable pane, ambiguous geometry, a blank unidentified row, and a bare shell prompt left after the agent exits. Each adapter contributes only capture and capability facts to the fleet-wide screen classifier in `bin/fm-composer-lib.sh`, which owns every shape and verdict. @@ -113,6 +114,7 @@ an ERROR in the daemon log, a durable catch-up if present), a tmux status-line flash when applicable, and a configurable backend-independent active alert. `docs/wedge-alarm.md` owns the alert channel setup, and `docs/verification/supervision.md` "Wedge-alarm channels" owns active evidence. So a guard false-positive becomes a visible stall, never an unbounded silent no-op. +`bin/fm-afk-launch.sh start`/`start-native` refuse a fresh entry when no channel can plausibly reach the captain (`docs/wedge-alarm.md` "Away-mode entry check"), so that promise is checked before the captain walks away, not just at failure time. ## Submit model diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 5df2a9d9915..096456d9432 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -39,6 +39,13 @@ # Supported backends: herdr, tmux. Others (zellij, orca, cmux) have no verified # non-visible-launch primitive here yet and refuse loudly. # +# Both start and start-native refuse a FRESH entry (not a refresh of an +# already-running daemon) when no active wedge-alarm channel is configured and +# this platform has no built-in default (fm-wedge-alarm-lib.sh's +# wedge_alarm_reliable_channel_configured; docs/wedge-alarm.md). Configure +# config/wedge-alarm with a command: directive, or set it to `off` to +# explicitly accept the durable marker as the only signal. +# # Test seam: FM_AFK_LAUNCH_ENTRY overrides the command run in the created # terminal (default bin/fm-afk-start.sh), so a topology test can run a harmless # placeholder instead of a real daemon. FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND @@ -79,6 +86,8 @@ FM_AFK_LAUNCH_WS_LABEL="firstmate-afk-daemon" . "$FM_AFK_LAUNCH_DIR/fm-backend.sh" # shellcheck source=bin/fm-supervisor-target-lib.sh . "$FM_AFK_LAUNCH_DIR/fm-supervisor-target-lib.sh" +# shellcheck source=bin/fm-wedge-alarm-lib.sh +. "$FM_AFK_LAUNCH_DIR/fm-wedge-alarm-lib.sh" # fm-afk-start.sh provides the daemon-lock liveness helpers and # fm_afk_clear_stale_artifacts; it is sourceable (BASH_SOURCE guard) and its # main does not run on source. It sets `set -eu`, so turn errexit back off for @@ -89,6 +98,27 @@ set +e fm_afk_launch_log() { printf 'fm-afk-launch: %s\n' "$*" >&2; } +# fm_afk_launch_wedge_alarm_preflight: refuse a FRESH away-mode entry when no +# active wedge-alarm channel is configured AND this platform has no built-in +# default (fm-wedge-alarm-lib.sh's wedge_alarm_reliable_channel_configured). +# Without this, a captain on a platform with no built-in channel (verified +# live, 2026-09-01: Linux, no osascript, no org.freedesktop.Notifications +# D-Bus service, and herdr's own "notification show" reporting +# {"reason":"disabled"} rather than posting anything) walks away believing the +# documented promise - a guard false-positive or a genuine stall becomes a +# VISIBLE alert - while every future max-defer wedge fires into a channel that +# was never going to reach anyone. Refusing here, before the captain leaves, is +# more honest than a silent marker discovered hours later. An explicit `off` in +# config/wedge-alarm is a deliberate acknowledgment and passes this check, the +# same as any other explicit channel directive; only the unconfigured/auto +# default that resolves to nothing is refused. +fm_afk_launch_wedge_alarm_preflight() { + wedge_alarm_reliable_channel_configured && return 0 + fm_afk_launch_log "refusing away-mode entry: no active wedge-alarm channel is configured, and this platform ($(uname)) has no built-in default; a stuck injection would raise the alarm but reach nobody but the durable state/.subsuper-inject-wedged marker (docs/wedge-alarm.md)" + fm_afk_launch_log "configure config/wedge-alarm with a command: directive that can reach you (a phone push, a pager, etc.), or set it to 'off' to explicitly accept the durable marker as the only signal, then retry" + return 1 +} + fm_afk_launch_lock_owned() { local pid expected actual [ -d "$FM_AFK_LAUNCH_LOCK" ] || return 1 @@ -482,6 +512,8 @@ fm_afk_launch_start() { return 0 fi + fm_afk_launch_wedge_alarm_preflight || return 1 + backup=$(mktemp -d "$FM_AFK_LAUNCH_STATE/.afk-launch-backup.XXXXXX") || return 1 if [ -f "$FM_AFK_LAUNCH_STATE/.afk" ]; then had_afk=1 @@ -540,6 +572,7 @@ fm_afk_launch_start_native() { fm_afk_launch_log "daemon already running; refreshed away-mode flag" return 0 fi + fm_afk_launch_wedge_alarm_preflight || return 1 backup=$(mktemp -d "$FM_AFK_LAUNCH_STATE/.afk-launch-backup.XXXXXX") || return 1 if [ -f "$FM_AFK_LAUNCH_STATE/.afk" ]; then had_afk=1 diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index caa39443b3b..bc6a950bf54 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -639,6 +639,25 @@ pane_is_busy() { # [backend] case "$native" in busy) return 0 ;; esac + # An affirmatively EMPTY composer is positive, structural proof the turn has + # ended: the harness renders either a live generating view or an idle prompt, + # never both, and inject_msg's own composer guard already requires exactly + # this verdict before it will ever type into the pane. Checking it here too + # stops the rendered-tail scan below from re-litigating a question this + # positive signal has already answered. + # Verified incident (2026-09-01, claude-on-herdr): the rendered scan has no + # positional anchor - it greps the last 12 non-blank lines for Claude's + # busy-shape signature ("…" + a parenthesized elapsed duration), which a + # live spinner satisfies but so can ordinary SETTLED reply text several + # lines back (e.g. "...holding steady… (2h into the soak test)"). With no + # new output to scroll that line out of view - exactly the state of an + # away-mode pane whose escalations keep failing to land - the false match + # persists for the rest of the session. A live busy pane never shows an + # affirmatively empty composer, so this short-circuit never masks a real + # busy turn. + if [ "$(fm_backend_composer_state "$backend" "$target" 2>/dev/null)" = empty ]; then + return 1 + fi tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || return 1 printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ | fm_busy_lines_match "$harness" @@ -719,52 +738,13 @@ escalate_flush() { # # skipped, never crashing the daemon loop - and the durable marker plus the tmux # flash stay exactly as before. # -# Config: config/wedge-alarm (local, gitignored), one channel directive per -# non-empty, non-comment line. FM_WEDGE_ALARM_CHANNEL overrides the file with a -# single directive. Directives: -# off disable the active alert entirely, regardless of position -# (marker + flash remain) -# auto | default platform default: macOS -> osascript; otherwise none -# osascript macOS Notification Center banner (backend-independent) -# herdr herdr UI notification (herdr notification show) -# command: run via `sh -c`, summary on $1 and on stdin -# An absent config means auto, i.e. default-ON on macOS: the alarm's whole -# purpose is to never be silent, so the reachable OS channel fires unless the -# captain explicitly disables it. - -# Print the configured channel directives, one per line. FM_WEDGE_ALARM_CHANNEL -# wins (a single directive); else each non-empty, non-comment line of -# config/wedge-alarm; else "auto". -wedge_alarm_configured_channels() { - local cfg line found= - if [ -n "${FM_WEDGE_ALARM_CHANNEL:-}" ]; then - printf '%s\n' "$FM_WEDGE_ALARM_CHANNEL" - return 0 - fi - cfg="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/wedge-alarm" - if [ -f "$cfg" ]; then - while IFS= read -r line || [ -n "$line" ]; do - line="${line#"${line%%[![:space:]]*}"}" - line="${line%"${line##*[![:space:]]}"}" - [ -n "$line" ] || continue - case "$line" in '#'*) continue ;; esac - printf '%s\n' "$line" - found=1 - done < "$cfg" - fi - [ -n "$found" ] || printf 'auto\n' -} - -# Resolve the platform's default OS-level channel for `auto`. macOS reaches the -# captain via an osascript Notification Center banner; other platforms have no -# built-in OS channel (the captain wires a command: directive), so this prints -# nothing and wedge_alarm_notify logs that the marker is the only signal. -wedge_alarm_platform_default() { - case "$(uname)" in - Darwin) command -v osascript >/dev/null 2>&1 && printf 'osascript' ;; - *) : ;; - esac -} +# Channel config, resolution, and the away-mode-entry reliability check are +# owned by fm-wedge-alarm-lib.sh (wedge_alarm_configured_channels, +# wedge_alarm_platform_default, wedge_alarm_reliable_channel_configured) so the +# runtime alert here and bin/fm-afk-launch.sh's entry-time refusal can never +# drift apart. +# shellcheck source=bin/fm-wedge-alarm-lib.sh +. "$FM_DAEMON_DIR/fm-wedge-alarm-lib.sh" wedge_alarm_run_bounded() { local channel=$1 timeout monitor_was_on=0 pid start elapsed rc diff --git a/bin/fm-wedge-alarm-lib.sh b/bin/fm-wedge-alarm-lib.sh new file mode 100755 index 00000000000..c2999e5778c --- /dev/null +++ b/bin/fm-wedge-alarm-lib.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# fm-wedge-alarm-lib.sh - the single owner of wedge-alarm CHANNEL RESOLUTION: +# what config/wedge-alarm and FM_WEDGE_ALARM_CHANNEL configure, and whether any +# of it can plausibly reach the captain. bin/fm-supervise-daemon.sh sources this +# for the runtime alert (wedge_alarm_notify calls the resolvers below), and +# bin/fm-afk-launch.sh sources it for the away-mode ENTRY check +# (wedge_alarm_reliable_channel_configured), so both consult the identical +# resolution instead of two copies drifting apart. +# +# Config: config/wedge-alarm (local, gitignored), one channel directive per +# non-empty, non-comment line. FM_WEDGE_ALARM_CHANNEL overrides the file with a +# single directive. Directives: +# off disable the active alert entirely, regardless of position +# (marker + flash remain) - also the explicit, deliberate +# acknowledgment that away-mode entry accepts below +# auto | default platform default: macOS -> osascript; otherwise none +# osascript macOS Notification Center banner (backend-independent) +# herdr herdr UI notification (herdr notification show) +# command: run via `sh -c`, summary on $1 and on stdin +# An absent config means auto, i.e. default-ON on macOS: the alarm's whole +# purpose is to never be silent, so the reachable OS channel fires unless the +# captain explicitly disables it. +# +# Away-mode entry check (verified live, 2026-09-01, Linux/WSL2): an absent or +# `auto` config resolves to NOTHING on any non-macOS platform - no built-in OS +# channel exists here, and `herdr notification show` itself reported +# {"reason":"disabled","shown":false} rather than actually posting anything, +# with no org.freedesktop.Notifications D-Bus service registered at all. A +# max-defer wedge under that silent configuration fires the alarm exactly as +# designed and STILL reaches nobody, because the design's promise - a guard +# false-positive becomes a visible stall, never a silent one - depends on SOME +# channel actually posting. wedge_alarm_reliable_channel_configured is the +# entry-time predicate: true for any EXPLICIT directive (an admitted `off` +# counts, because the captain has consciously accepted marker-only), false only +# when every configured line is `auto`/`default` and the platform resolves it +# to nothing. bin/fm-afk-launch.sh refuses entry loudly on false rather than +# letting the captain walk away believing a channel that will never fire. + +# Print the configured channel directives, one per line. FM_WEDGE_ALARM_CHANNEL +# wins (a single directive); else each non-empty, non-comment line of +# config/wedge-alarm; else "auto". +wedge_alarm_configured_channels() { + local cfg line found= + if [ -n "${FM_WEDGE_ALARM_CHANNEL:-}" ]; then + printf '%s\n' "$FM_WEDGE_ALARM_CHANNEL" + return 0 + fi + cfg="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/wedge-alarm" + if [ -f "$cfg" ]; then + while IFS= read -r line || [ -n "$line" ]; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [ -n "$line" ] || continue + case "$line" in '#'*) continue ;; esac + printf '%s\n' "$line" + found=1 + done < "$cfg" + fi + [ -n "$found" ] || printf 'auto\n' +} + +# Resolve the platform's default OS-level channel for `auto`. macOS reaches the +# captain via an osascript Notification Center banner; other platforms have no +# built-in OS channel (the captain wires a command: directive), so this prints +# nothing and wedge_alarm_notify logs that the marker is the only signal. +wedge_alarm_platform_default() { + case "$(uname)" in + Darwin) command -v osascript >/dev/null 2>&1 && printf 'osascript' ;; + *) : ;; + esac +} + +# wedge_alarm_reliable_channel_configured: 0 when at least one configured +# directive is a directive wedge_alarm_notify's dispatch actually recognizes - +# off, osascript, herdr, command: with a NON-EMPTY - or an +# auto/default that resolves to a real platform channel; 1 when every +# configured line is auto/default and none resolve to anything, OR when a +# configured line is not a recognized directive at all (a typo or malformed +# line would otherwise pass this check as "reliable" while +# wedge_alarm_notify's dispatch silently no-ops on it at runtime) - both are +# the silent-alarm gaps this predicate exists to catch. A bare `command:` with +# no payload, or one with only whitespace after the colon, falls into the same +# unrecognized-directive rejection as a typo: `sh -c ' '` runs and exits 0 +# without doing anything, so wedge_alarm_via_command's own `[ -n "$cmd" ]` +# guard - which treats whitespace as non-empty - can never catch it either; +# treating it as reliable would be exactly the reassurance-that-doesn't-hold +# gap this predicate exists to catch. osascript and herdr are not given the +# same binary-presence check: unlike an empty command: payload, which can +# never work on any machine, a missing osascript/herdr binary is an +# environmental fact that can differ by host and change over time - the same +# category of runtime risk as a command: whose itself is broken, +# which this predicate deliberately leaves to wedge_alarm_notify's +# best-effort, logged dispatch rather than validating at entry time. +wedge_alarm_reliable_channel_configured() { + local ch found_real=1 + while IFS= read -r ch; do + [ -n "$ch" ] || continue + case "$ch" in + auto|default) + [ -n "$(wedge_alarm_platform_default)" ] && found_real=0 + ;; + off|osascript|herdr|command:*[![:space:]]*) + found_real=0 + ;; + *) + return 1 + ;; + esac + done < <(wedge_alarm_configured_channels) + return "$found_real" +} diff --git a/docs/scripts.md b/docs/scripts.md index 5bbcebfdf1b..4949f5fee49 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -88,6 +88,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-afk-return.sh` | Own deterministic return shutdown, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | | `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, guard injection by the detected primary harness, escalate batched digests, alert on failed delivery | +| `fm-wedge-alarm-lib.sh` | Single owner of wedge-alarm channel resolution shared by the daemon's runtime alert and the launcher's away-mode entry check | | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | | `fm-nm-run-lib.sh` | Single owner of shared no-mistakes run-attribution primitives and rules | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 927c500562c..c230bdab592 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -500,6 +500,43 @@ tests/fm-claude-stop-autoarm.test.sh tests/fm-turnend-guard.test.sh ``` +## Away-mode supervisor busy guard + +Investigated 2026-09-01 (claude-on-herdr, Herdr 0.8.2, Claude Code 2.1.257) after two real away-mode sessions delivered zero escalations for over two hours each, every 15s cycle logging `inject deferred: supervisor pane busy (agent mid-turn)` while the supervisor pane was actually idle. + +The primary hypothesis under investigation - that hosting the daemon as a harness-native tracked background job makes `pane_is_busy` read its own hosting pane as permanently busy - was DISPROVEN by live measurement in an isolated Herdr lab session (`tests/`'s pattern; a real `claude` process, not a stub). None of the following tripped `fm_backend_busy_state`/`fm_busy_lines_match` while the pane was genuinely idle: + +- A single or long-lived (900s) tracked background shell in the hosting pane. +- The real `bin/fm-supervise-daemon.sh`, launched exactly as production does (`bin/fm-afk-start.sh` via Claude's native background-bash tool) against a throwaway `FM_HOME`, targeting its own hosting pane via `HERDR_ENV`/`HERDR_PANE_ID` auto-discovery - confirmed by a real seeded escalation being delivered end to end with the background daemon and an extra background shell both alive. +- A live background Task-tool subagent running concurrently. + +The actual cause: `pane_is_busy`'s rendered-tail fallback (`fm_busy_lines_match`, used when Herdr's native agent-state is not `busy`) scans the last 12 non-blank lines of the pane with no positional anchor. +Claude's busy-shape signature (an ellipsis followed by a parenthesized elapsed duration, `…[[:space:]]+\([0-9]+[smh]`) is satisfied by a live spinner footer, but nothing in the match requires that - it is equally satisfied by ordinary SETTLED reply text sitting several lines back in scrollback, for example a firstmate reply mentioning "...holding steady… (2h into the soak test)". + +Reproduced live: + +```sh +# idle pane, no matching text on screen +fm_backend_busy_state herdr "$TARGET" # -> idle +# same idle pane, after the pane's own PRIOR reply contains "...(2h into the soak test)" +fm_backend_busy_state herdr "$TARGET" # native busy_state is unaffected (still idle); + # pane_is_busy's rendered fallback now matches -> busy +``` + +With no new output ever landing while escalations keep failing to deliver, nothing scrolls the offending line out of the scanned window, so the false match persists for the rest of the session - matching the observed multi-hour, cross-restart persistence exactly. +The fix (`bin/fm-supervise-daemon.sh`'s `pane_is_busy`) checks `fm_backend_composer_state` for an affirmatively `empty` composer before running the rendered scan: a genuinely busy pane never reads an empty composer (the harness renders either a live generating view or an idle prompt, never both), so this short-circuit cannot mask a real busy turn. +Regression coverage: `tests/fm-daemon.test.sh`'s `test_pane_is_busy_empty_composer_overrides_settled_scrollback_match`. + +The terminal-backed launch path (`bin/fm-afk-launch.sh start`) and the native-hosted path (`start-native`) share the identical `pane_is_busy` call against the same captain-pane target, so this was never specific to native hosting; both paths are fixed by the same change, and no afk-skill harness-routing change was needed. + +Deterministic entry points: + +```sh +tests/fm-daemon.test.sh +tests/fm-afk-inject-e2e.test.sh +tests/fm-afk-inject-herdr-e2e.test.sh +``` + ## Wedge-alarm channels The two real notification channels were bounded manually on 2026-07-10 on macOS 26.5.2 with Herdr 0.7.3. @@ -532,3 +569,21 @@ Observed output: ``` The safe command-channel contract is covered without a notification by `tests/fm-daemon.test.sh`: the summary reaches both `$1` and stdin, every channel is process-group bounded, and a failed channel falls through. + +### No reliable Linux default, 2026-09-01 + +Bounded manually on Linux 6.6.87.2-microsoft-standard-WSL2, Herdr 0.8.2, in an isolated Herdr lab session (no client attached to that headless server): + +```sh +$ herdr notification show 'wedge-test' --body probe --sound none --session "$LAB_SESSION" +{"id":"cli:notification:show","result":{"reason":"disabled","shown":false,"type":"notification_show"}} +$ dbus-send --session --dest=org.freedesktop.Notifications --type=method_call --print-reply \ + /org/freedesktop/Notifications org.freedesktop.Notifications.GetServerInformation +Error org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.Notifications was not provided by any .service files +$ command -v notify-send; command -v osascript # both absent +``` + +No `org.freedesktop.Notifications` D-Bus service is registered on this machine at all, so neither the `herdr` channel nor a hypothetical `notify-send`-style channel has anywhere to deliver to; `auto`/`default` (the config-absent case) resolves to nothing here, matching `wedge_alarm_platform_default`'s documented non-macOS behavior. +This is why the entry-time refusal (`bin/fm-wedge-alarm-lib.sh`'s `wedge_alarm_reliable_channel_configured`, wired into `bin/fm-afk-launch.sh`) was chosen over asserting a working default Linux channel: none was clearly available to assert. + +Deterministic entry point: `tests/fm-afk-launch.test.sh`. diff --git a/docs/wedge-alarm.md b/docs/wedge-alarm.md index cfee3784b42..62fe6f79322 100644 --- a/docs/wedge-alarm.md +++ b/docs/wedge-alarm.md @@ -21,6 +21,13 @@ It lists channel directives, one per non-empty, non-comment line, and every list An absent `config/wedge-alarm` behaves as `auto`, which is default-on on macOS. This is deliberate because the alarm fires only after a genuine max-defer wedge and is rate-limited to at most once per max-defer window. +## Away-mode entry check + +On a platform with no built-in `auto` channel (verified live, 2026-09-01: Linux, no `osascript`, no `org.freedesktop.Notifications` D-Bus service), `bin/fm-afk-launch.sh start` and `start-native` refuse a fresh away-mode entry rather than let the captain walk away believing a channel exists that will never fire. +The same refusal fires when every configured line is not one of the recognized directives above (`off`, `osascript`, `herdr`, `command:`, or a resolving `auto`/`default`), so a typo or malformed line cannot pass as reliable while silently no-opping at alarm time. +Configure `config/wedge-alarm` with a working `command:` directive, or set it to `off` to explicitly accept the durable marker as the only signal. +`bin/fm-wedge-alarm-lib.sh`'s `wedge_alarm_reliable_channel_configured` owns this check; it is not consulted again on a refresh of an already-running daemon. + Each channel is best-effort. A missing binary or non-zero exit logs a warning and continues to the next channel without crashing the daemon loop. Every invocation is process-group bounded by `FM_WEDGE_ALARM_TIMEOUT_SECS`, which defaults to 10 seconds, including `command:`, `osascript`, `herdr`, and the test seam. diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 6d0c7bd9d1a..01025ef52c7 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -219,7 +219,7 @@ unit_failed_start_rolls_back_state() { printf 'pending\n' > "$st/state/.subsuper-escalations" printf 'wedged\n' > "$st/state/.subsuper-inject-wedged" if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET=unused \ - FM_SUPERVISOR_BACKEND=unsupported "$LAUNCH" start >/dev/null 2>&1; then + FM_SUPERVISOR_BACKEND=unsupported FM_WEDGE_ALARM_CHANNEL=off "$LAUNCH" start >/dev/null 2>&1; then fail "failed start: unsupported backend unexpectedly succeeded" elif [ ! -e "$st/state/.afk" ] \ && [ "$(cat "$st/state/.subsuper-escalations")" = pending ] \ @@ -240,9 +240,11 @@ unit_concurrent_start_serialized() { TRACK_TMUX_SESSIONS="$TRACK_TMUX_SESSIONS $cap_session" cap_pane=$(tmux display-message -p -t "$cap_session" '#{pane_id}') FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET="$cap_pane" \ - FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start >/dev/null 2>&1 & first=$! + FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" FM_WEDGE_ALARM_CHANNEL=off \ + "$LAUNCH" start >/dev/null 2>&1 & first=$! FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET="$cap_pane" \ - FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start >/dev/null 2>&1 & second=$! + FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" FM_WEDGE_ALARM_CHANNEL=off \ + "$LAUNCH" start >/dev/null 2>&1 & second=$! wait "$first"; wait "$second" rec=$(cut -f2 "$st/state/.afk-daemon-terminal" 2>/dev/null || true) count=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | awk -v expected="$rec" '$0 == expected {n++} END{print n+0}') @@ -488,7 +490,7 @@ 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_WEDGE_ALARM_CHANNEL=off "$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 @@ -759,7 +761,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_WEDGE_ALARM_CHANNEL=off bash -c ' . "$1" fm_afk_launch_reconcile() { return 0; } fm_afk_clear_stale_artifacts() { return 1; } @@ -812,7 +814,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_WEDGE_ALARM_CHANNEL=off bash -c ' . "$1" fm_afk_launch_flag_write() { return 1; } ! fm_afk_launch_start_native @@ -825,6 +827,109 @@ unit_flag_write_failure_aborts() { rm -rf "$st" } +# --------------------------------------------------------------------------- +# UNIT: fm_afk_launch_wedge_alarm_preflight (fresh-entry refusal when no +# reliable wedge-alarm channel is configured; fm-wedge-alarm-lib.sh). +# --------------------------------------------------------------------------- +make_wedge_preflight_case() { # -> echoes dir; creates state/ and a fake non-Darwin uname on PATH + local name=$1 dir fakebin + dir=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-wedge-preflight-$name.XXXXXX") + fakebin="$dir/fakebin" + mkdir -p "$dir/state" "$fakebin" + cat > "$fakebin/uname" <<'SH' +#!/usr/bin/env bash +printf '%s\n' Linux +SH + chmod +x "$fakebin/uname" + printf '%s\n' "$dir" +} + +unit_wedge_alarm_preflight_refuses_without_channel() { + local st out rc + st=$(make_wedge_preflight_case no-channel) + # shellcheck disable=SC2016 # $1 expands inside the bash -c subshell, not here. + out=$(PATH="$st/fakebin:$PATH" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + env -u FM_WEDGE_ALARM_CHANNEL bash -c '. "$1"; fm_afk_launch_wedge_alarm_preflight' _ "$LAUNCH" 2>&1) + rc=$? + if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q "no active wedge-alarm channel is configured"; then + pass "wedge-alarm preflight: refuses a fresh entry with no configured channel and no platform default" + else + fail "wedge-alarm preflight: did not refuse with no channel configured (rc=$rc): $out" + fi + rm -rf "$st" +} + +unit_wedge_alarm_preflight_accepts_explicit_off() { + local st out rc + st=$(make_wedge_preflight_case explicit-off) + out=$(PATH="$st/fakebin:$PATH" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_WEDGE_ALARM_CHANNEL=off \ + bash -c '. "$1"; fm_afk_launch_wedge_alarm_preflight' _ "$LAUNCH" 2>&1) + rc=$? + if [ "$rc" -eq 0 ] && [ -z "$out" ]; then + pass "wedge-alarm preflight: an explicit 'off' acknowledgment passes entry" + else + fail "wedge-alarm preflight: explicit 'off' unexpectedly refused entry (rc=$rc): $out" + fi + rm -rf "$st" +} + +unit_wedge_alarm_preflight_refuses_malformed_directive() { + local st out rc + st=$(make_wedge_preflight_case malformed) + out=$(PATH="$st/fakebin:$PATH" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_WEDGE_ALARM_CHANNEL=oascript \ + bash -c '. "$1"; fm_afk_launch_wedge_alarm_preflight' _ "$LAUNCH" 2>&1) + rc=$? + if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q "no active wedge-alarm channel is configured"; then + pass "wedge-alarm preflight: refuses entry on an unrecognized/malformed channel directive" + else + fail "wedge-alarm preflight: a malformed directive ('oascript') was accepted as reliable (rc=$rc): $out" + fi + rm -rf "$st" +} + +unit_wedge_alarm_preflight_refuses_empty_command_directive() { + local st out rc + st=$(make_wedge_preflight_case empty-command) + out=$(PATH="$st/fakebin:$PATH" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_WEDGE_ALARM_CHANNEL='command:' \ + bash -c '. "$1"; fm_afk_launch_wedge_alarm_preflight' _ "$LAUNCH" 2>&1) + rc=$? + if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q "no active wedge-alarm channel is configured"; then + pass "wedge-alarm preflight: refuses entry on a bare 'command:' directive with no payload" + else + fail "wedge-alarm preflight: a bare 'command:' directive was accepted as reliable (rc=$rc): $out" + fi + rm -rf "$st" +} + +unit_wedge_alarm_preflight_refuses_whitespace_command_directive() { + local st out rc + st=$(make_wedge_preflight_case whitespace-command) + out=$(PATH="$st/fakebin:$PATH" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_WEDGE_ALARM_CHANNEL='command: ' \ + bash -c '. "$1"; fm_afk_launch_wedge_alarm_preflight' _ "$LAUNCH" 2>&1) + rc=$? + if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q "no active wedge-alarm channel is configured"; then + pass "wedge-alarm preflight: refuses entry on a 'command:' directive with a whitespace-only payload" + else + fail "wedge-alarm preflight: a whitespace-only 'command:' directive was accepted as reliable (rc=$rc): $out" + fi + rm -rf "$st" +} + +unit_wedge_alarm_preflight_accepts_command_with_payload() { + local st out rc + st=$(make_wedge_preflight_case command-payload) + out=$(PATH="$st/fakebin:$PATH" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_WEDGE_ALARM_CHANNEL='command:/usr/bin/notify-send' \ + bash -c '. "$1"; fm_afk_launch_wedge_alarm_preflight' _ "$LAUNCH" 2>&1) + rc=$? + if [ "$rc" -eq 0 ] && [ -z "$out" ]; then + pass "wedge-alarm preflight: a 'command:' directive with a real payload passes entry" + else + fail "wedge-alarm preflight: 'command:' with a payload unexpectedly refused entry (rc=$rc): $out" + fi + rm -rf "$st" +} + # --------------------------------------------------------------------------- # E2E herdr: topology invariant. # --------------------------------------------------------------------------- @@ -863,6 +968,7 @@ e2e_herdr() { FM_HOME="$home_tmp" FM_STATE_OVERRIDE="$home_tmp/state" \ FM_SUPERVISOR_TARGET="$target" FM_SUPERVISOR_BACKEND=herdr FM_AFK_LAUNCH_ENTRY="$SLEEPER" \ + FM_WEDGE_ALARM_CHANNEL=off \ "$LAUNCH" start >/dev/null 2>&1 during=$(fm_backend_herdr_cli "$SESSION" pane list --workspace "$cap_ws" 2>/dev/null | jq --arg t "$cap_tab" '[.result.panes[]?|select(.tab_id==$t)]|length') @@ -903,6 +1009,7 @@ e2e_tmux() { FM_HOME="$home_tmp" FM_STATE_OVERRIDE="$home_tmp/state" \ FM_SUPERVISOR_TARGET="$cap_pane" FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" \ + FM_WEDGE_ALARM_CHANNEL=off \ "$LAUNCH" start >/dev/null 2>&1 during=$(tmux list-panes -t "$cap_session" | wc -l | tr -d ' ') @@ -955,6 +1062,12 @@ unit_clear_failure_aborts_entry unit_confirmed_absence_succeeds unit_incomplete_restore_retains_backup unit_flag_write_failure_aborts +unit_wedge_alarm_preflight_refuses_without_channel +unit_wedge_alarm_preflight_accepts_explicit_off +unit_wedge_alarm_preflight_refuses_malformed_directive +unit_wedge_alarm_preflight_refuses_empty_command_directive +unit_wedge_alarm_preflight_refuses_whitespace_command_directive +unit_wedge_alarm_preflight_accepts_command_with_payload e2e_herdr e2e_tmux diff --git a/tests/fm-afk-pi-herdr-return-e2e.test.sh b/tests/fm-afk-pi-herdr-return-e2e.test.sh index 98c826085f7..f4fc91e61cb 100755 --- a/tests/fm-afk-pi-herdr-return-e2e.test.sh +++ b/tests/fm-afk-pi-herdr-return-e2e.test.sh @@ -192,6 +192,7 @@ EOF PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" FM_AFK_LAUNCH_ENTRY="$TMP_ROOT/daemon-entry" \ + FM_WEDGE_ALARM_CHANNEL=off \ "$ROOT/bin/fm-afk-launch.sh" start >/dev/null DAEMON_STARTED=1 for _ in $(seq 1 100); do [ -s "$STATE/.supervise-daemon.pid" ] && break; sleep 0.1; done @@ -284,6 +285,7 @@ PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJE # idempotently clear because the keyed blocker is resolved. PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="$PRIMARY_TARGET" FM_AFK_LAUNCH_ENTRY="$TMP_ROOT/daemon-entry" \ + FM_WEDGE_ALARM_CHANNEL=off \ "$ROOT/bin/fm-afk-launch.sh" start >/dev/null DAEMON_STARTED=1 PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 38403028be2..0055a6e7a50 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -2467,6 +2467,7 @@ test_pane_is_busy_herdr_native_busy_state() { test_primary_busy_guard_is_harness_scoped() { ( fm_backend_busy_state() { printf 'unknown'; } + fm_backend_composer_state() { printf 'pending'; } fm_backend_capture() { printf 'esc interrupt\n'; } if FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr; then fail "OpenCode's rendered signature must not classify a Claude primary busy" @@ -2477,6 +2478,35 @@ test_primary_busy_guard_is_harness_scoped() { pass "primary busy guard isolates rendered signatures by detected harness" } +# Verified live, 2026-09-01 (claude-on-herdr): pane_is_busy's rendered-tail scan +# has no positional anchor, so Claude's busy-shape signature ("…" + a +# parenthesized elapsed duration) can match ordinary SETTLED reply text sitting +# several lines back in scrollback, not just a live spinner footer. With no new +# output to scroll that line out of view - exactly an away-mode pane whose +# escalations keep failing to land - the false match persisted for the rest of +# the session across three separate daemon restarts. An affirmatively empty +# composer is positive, structural proof the turn has ended (the harness shows +# either a live generating view or an idle prompt, never both), so it must +# short-circuit the scan before the scan ever runs. +test_pane_is_busy_empty_composer_overrides_settled_scrollback_match() { + ( + fm_backend_busy_state() { printf 'idle'; } + fm_backend_composer_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected composer_state args: $1 $2"; printf 'empty'; } + fm_backend_capture() { fail "the rendered-tail scan must not run once the composer is proven empty"; } + if FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr; then + fail "an affirmatively empty composer must not be overridden by a settled-scrollback regex match" + fi + ) || fail "empty-composer override subshell failed" + ( + fm_backend_busy_state() { printf 'idle'; } + fm_backend_composer_state() { printf 'pending'; } + fm_backend_capture() { printf 'Captain, the fix is deployed and holding steady… (2h into the soak test)\n'; } + FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr \ + || fail "a genuinely unproven composer should still fall through to the rendered-tail scan" + ) || fail "non-empty-composer fallback subshell failed" + pass "pane_is_busy: an affirmatively empty composer overrides a settled-scrollback signature match" +} + test_pane_is_busy_defaults_to_tmux_when_backend_omitted() { local dir fakebin capture dir=$(make_supercase busy-default-backend) @@ -2730,6 +2760,7 @@ test_discover_supervisor_backend_precedence test_discover_supervisor_target_herdr test_pane_is_busy_herdr_native_busy_state test_primary_busy_guard_is_harness_scoped +test_pane_is_busy_empty_composer_overrides_settled_scrollback_match test_pane_is_busy_defaults_to_tmux_when_backend_omitted test_pane_input_pending_herdr_dispatch test_inject_msg_herdr_busy_guard_defers