From 6b55f5225dbf1c105ab807444c33875100eaeb10 Mon Sep 17 00:00:00 2001 From: ktapa <131788846+ktapa@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:05:12 -0400 Subject: [PATCH 1/6] fix: stop away-mode's busy guard from wedging on settled scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real away-mode sessions delivered zero escalations for 2+ hours each because pane_is_busy() misread the captain's idle pane as busy on every 15s cycle. Live measurement in an isolated Herdr lab disproved the suspected cause (the daemon's own tracked background job wedging its hosting pane): a real fm-supervise-daemon.sh, launched exactly as production does and targeting its own hosting pane, delivered a seeded escalation successfully with a background shell alive the whole time. The actual cause: pane_is_busy's rendered-tail fallback has no positional anchor. Claude's busy-shape signature ("…" + a parenthesized elapsed duration) is meant to catch a live spinner footer, but it matches equally well against ordinary SETTLED reply text sitting in scrollback (e.g. "... holding steady… (2h into the soak test)"). With no new output landing while escalations keep failing, nothing scrolls the line out of view, so the false match persists for the rest of the session - reproduced live and confirmed as the trigger. Fix: pane_is_busy now treats an affirmatively empty composer as positive proof the turn has ended and short-circuits the rendered scan before it runs. A genuinely busy pane never reads an empty composer, so this can't mask a real busy turn. Both the terminal-backed and native-hosted launch paths share this same check, so neither needed separate handling. Separately, the wedge alarm's own escape hatch was inert on this Linux box: no osascript, no org.freedesktop.Notifications D-Bus service, and even herdr's own notification channel reported {"reason":"disabled"} rather than posting anything. Extracted the channel-resolution logic into bin/fm-wedge-alarm-lib.sh (shared by the daemon and the launcher) and added an entry-time refusal: fm-afk-launch.sh now refuses a fresh away-mode entry when no channel can plausibly reach the captain, unless the captain has explicitly configured one (including an explicit "off" acknowledging marker-only). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019924TgGwpoBc2rKMbFgi67 --- .agents/skills/afk/SKILL.md | 2 + bin/fm-afk-launch.sh | 33 +++++++++ bin/fm-supervise-daemon.sh | 72 +++++++------------ bin/fm-wedge-alarm-lib.sh | 92 ++++++++++++++++++++++++ docs/verification/supervision.md | 55 ++++++++++++++ docs/wedge-alarm.md | 6 ++ tests/fm-afk-launch.test.sh | 14 ++-- tests/fm-afk-pi-herdr-return-e2e.test.sh | 2 + tests/fm-daemon.test.sh | 31 ++++++++ 9 files changed, 256 insertions(+), 51 deletions(-) create mode 100755 bin/fm-wedge-alarm-lib.sh 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..d6504635a2f --- /dev/null +++ b/bin/fm-wedge-alarm-lib.sh @@ -0,0 +1,92 @@ +#!/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 an explicit choice (off, osascript, herdr, command:) or an +# auto/default that resolves to a real platform channel; 1 only when every +# configured line is auto/default and none of them resolve to anything - the +# silent-by-default gap this predicate exists to catch. +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 + ;; + *) + found_real=0 + ;; + esac + done < <(wedge_alarm_configured_channels) + return "$found_real" +} 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..58d1d599c6c 100644 --- a/docs/wedge-alarm.md +++ b/docs/wedge-alarm.md @@ -21,6 +21,12 @@ 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. +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..470a352c3df 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -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 @@ -863,6 +865,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 +906,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 ' ') 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 From 96171e210904d06d82364b5032728c0e7388bed8 Mon Sep 17 00:00:00 2001 From: ktapa <131788846+ktapa@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:29:20 -0400 Subject: [PATCH 2/6] no-mistakes(review): Fix wedge-alarm rollback test, validate directives, add preflight tests --- bin/fm-wedge-alarm-lib.sh | 16 +++++++--- tests/fm-afk-launch.test.sh | 64 ++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/bin/fm-wedge-alarm-lib.sh b/bin/fm-wedge-alarm-lib.sh index d6504635a2f..1d5feb54039 100755 --- a/bin/fm-wedge-alarm-lib.sh +++ b/bin/fm-wedge-alarm-lib.sh @@ -71,10 +71,13 @@ wedge_alarm_platform_default() { } # wedge_alarm_reliable_channel_configured: 0 when at least one configured -# directive is an explicit choice (off, osascript, herdr, command:) or an -# auto/default that resolves to a real platform channel; 1 only when every -# configured line is auto/default and none of them resolve to anything - the -# silent-by-default gap this predicate exists to catch. +# directive is a directive wedge_alarm_notify's dispatch actually recognizes - +# off, osascript, herdr, command: - 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. wedge_alarm_reliable_channel_configured() { local ch found_real=1 while IFS= read -r ch; do @@ -83,9 +86,12 @@ wedge_alarm_reliable_channel_configured() { auto|default) [ -n "$(wedge_alarm_platform_default)" ] && found_real=0 ;; - *) + off|osascript|herdr|command:*) found_real=0 ;; + *) + return 1 + ;; esac done < <(wedge_alarm_configured_channels) return "$found_real" diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 470a352c3df..4f6b35f8255 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 ] \ @@ -827,6 +827,65 @@ 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) + 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" +} + # --------------------------------------------------------------------------- # E2E herdr: topology invariant. # --------------------------------------------------------------------------- @@ -959,6 +1018,9 @@ 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 e2e_herdr e2e_tmux From 00195f262917bac2668f55b424b4fd874755390e Mon Sep 17 00:00:00 2001 From: ktapa <131788846+ktapa@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:35:12 -0400 Subject: [PATCH 3/6] no-mistakes(document): docs: note directive validation and list fm-wedge-alarm-lib.sh in toolbelt --- docs/scripts.md | 1 + docs/wedge-alarm.md | 1 + 2 files changed, 2 insertions(+) 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/wedge-alarm.md b/docs/wedge-alarm.md index 58d1d599c6c..62fe6f79322 100644 --- a/docs/wedge-alarm.md +++ b/docs/wedge-alarm.md @@ -24,6 +24,7 @@ This is deliberate because the alarm fires only after a genuine max-defer wedge ## 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. From e8341c611cab26a758faf8f77f0483a50e7030ba Mon Sep 17 00:00:00 2001 From: ktapa <131788846+ktapa@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:38:37 -0400 Subject: [PATCH 4/6] no-mistakes(lint): {"summary": "suppress false-positive SC2016 in wedge-alarm preflight test"} --- tests/fm-afk-launch.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 4f6b35f8255..52cecd2648b 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -847,6 +847,7 @@ SH 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=$? From 3d847c1ec0f7b3407fe8975c16c2d5c6d928fb1d Mon Sep 17 00:00:00 2001 From: ktapa <131788846+ktapa@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:19:31 -0400 Subject: [PATCH 5/6] fix: reject a bare command: wedge-alarm directive at entry wedge_alarm_reliable_channel_configured matched command:* as a reliable directive even with an empty payload, so a config/wedge-alarm containing only "command:" passed away-mode entry as covered while wedge_alarm_via_command's own non-empty-cmd guard silently no-ops it at alarm time - the exact reassurance-that-doesn't-hold gap this predicate exists to catch. Require at least one character after the colon (command:?*) so an empty payload falls into the same immediate rejection as any other unrecognized directive. osascript and herdr are left unchanged: a missing binary is a per-host environmental fact, the same class of runtime risk this predicate already leaves to wedge_alarm_notify's best-effort logged dispatch for a broken command: payload, not a config-only defect like an empty command:. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LnXRSBGuVx1oZEnPdKxm8F --- bin/fm-wedge-alarm-lib.sh | 25 ++++++++++++++++++------- tests/fm-afk-launch.test.sh | 31 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/bin/fm-wedge-alarm-lib.sh b/bin/fm-wedge-alarm-lib.sh index 1d5feb54039..436a41bec44 100755 --- a/bin/fm-wedge-alarm-lib.sh +++ b/bin/fm-wedge-alarm-lib.sh @@ -72,12 +72,23 @@ wedge_alarm_platform_default() { # 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: - 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. +# 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 falls into the same unrecognized-directive rejection as a typo: +# wedge_alarm_via_command's own `[ -n "$cmd" ]` guard can never fire it, so +# 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 @@ -86,7 +97,7 @@ wedge_alarm_reliable_channel_configured() { auto|default) [ -n "$(wedge_alarm_platform_default)" ] && found_real=0 ;; - off|osascript|herdr|command:*) + off|osascript|herdr|command:?*) found_real=0 ;; *) diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 52cecd2648b..7f010eeb817 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -887,6 +887,35 @@ unit_wedge_alarm_preflight_refuses_malformed_directive() { 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_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. # --------------------------------------------------------------------------- @@ -1022,6 +1051,8 @@ 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_accepts_command_with_payload e2e_herdr e2e_tmux From a542c4b3d695483c1e8b68f06e5801ad6c49d6c3 Mon Sep 17 00:00:00 2001 From: ktapa <131788846+ktapa@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:34:31 -0400 Subject: [PATCH 6/6] =?UTF-8?q?no-mistakes(ci):=20Fixed=20the=20Greptile-f?= =?UTF-8?q?lagged=20gap=20in=20bin/fm-wedge-alarm-lib.sh:=20`wedge=5Falarm?= =?UTF-8?q?=5Freliable=5Fchannel=5Fconfigured`=20used=20the=20case=20patte?= =?UTF-8?q?rn=20`command:=3F*`,=20which=20requires=20only=20one=20characte?= =?UTF-8?q?r=20after=20the=20colon=20=E2=80=94=20so=20`command:=20`=20(or?= =?UTF-8?q?=20any=20all-whitespace=20payload)=20matched=20and=20was=20acce?= =?UTF-8?q?pted=20as=20a=20"reliable"=20channel=20at=20away-mode=20entry.?= =?UTF-8?q?=20At=20runtime,=20`wedge=5Falarm=5Fvia=5Fcommand`'s=20`[=20-n?= =?UTF-8?q?=20"$cmd"=20]`=20guard=20treats=20whitespace=20as=20non-empty?= =?UTF-8?q?=20too,=20so=20`sh=20-c=20'=20'`=20would=20run,=20do=20nothing,?= =?UTF-8?q?=20and=20exit=200=20=E2=80=94=20silently=20no-op'ing=20the=20al?= =?UTF-8?q?arm=20with=20no=20signal=20to=20the=20captain,=20reproducing=20?= =?UTF-8?q?the=20exact=20fail-silent=20gap=20this=20PR's=20user=20intent?= =?UTF-8?q?=20targets.=20Fix:=20changed=20the=20pattern=20to=20`command:*[?= =?UTF-8?q?![:space:]]*`,=20requiring=20at=20least=20one=20non-whitespace?= =?UTF-8?q?=20character=20after=20the=20colon,=20so=20both=20a=20bare=20`c?= =?UTF-8?q?ommand:`=20and=20a=20whitespace-only=20`command:=20`=20are=20re?= =?UTF-8?q?jected=20at=20entry=20(falling=20into=20the=20existing=20"no=20?= =?UTF-8?q?active=20wedge-alarm=20channel=20is=20configured"=20refusal),?= =?UTF-8?q?=20while=20`command:`=20still=20passes.=20Updated?= =?UTF-8?q?=20the=20adjacent=20comment=20to=20document=20the=20whitespace?= =?UTF-8?q?=20case.=20Added=20a=20regression=20test=20(`unit=5Fwedge=5Fala?= =?UTF-8?q?rm=5Fpreflight=5Frefuses=5Fwhitespace=5Fcommand=5Fdirective`=20?= =?UTF-8?q?in=20tests/fm-afk-launch.test.sh)=20that=20pins=20FM=5FWEDGE=5F?= =?UTF-8?q?ALARM=5FCHANNEL=3D'command:=20'=20being=20rejected;=20verified?= =?UTF-8?q?=20it=20fails=20on=20the=20pre-fix=20code=20and=20passes=20afte?= =?UTF-8?q?r=20the=20fix.=20Confirmed=20the=20CI/CD=20runs=20for=20this=20?= =?UTF-8?q?PR=20are=20conclusion=3Daction=5Frequired=20(workflow-approval?= =?UTF-8?q?=20gating,=20not=20a=20real=20test=20failure),=20so=20the=20onl?= =?UTF-8?q?y=20concrete=20code=20defect=20to=20address=20was=20the=20Grept?= =?UTF-8?q?ile=20finding.=20Full=20fm-afk-launch.test.sh=20and=20fm-daemon?= =?UTF-8?q?.test.sh=20suites=20pass=20(all=20wedge-alarm=20preflight=20+?= =?UTF-8?q?=20dispatch=20tests=20green),=20and=20shellcheck=20is=20clean?= =?UTF-8?q?=20on=20both=20changed=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/fm-wedge-alarm-lib.sh | 8 +++++--- tests/fm-afk-launch.test.sh | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/bin/fm-wedge-alarm-lib.sh b/bin/fm-wedge-alarm-lib.sh index 436a41bec44..c2999e5778c 100755 --- a/bin/fm-wedge-alarm-lib.sh +++ b/bin/fm-wedge-alarm-lib.sh @@ -79,8 +79,10 @@ wedge_alarm_platform_default() { # 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 falls into the same unrecognized-directive rejection as a typo: -# wedge_alarm_via_command's own `[ -n "$cmd" ]` guard can never fire it, so +# 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 @@ -97,7 +99,7 @@ wedge_alarm_reliable_channel_configured() { auto|default) [ -n "$(wedge_alarm_platform_default)" ] && found_real=0 ;; - off|osascript|herdr|command:?*) + off|osascript|herdr|command:*[![:space:]]*) found_real=0 ;; *) diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 7f010eeb817..01025ef52c7 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -901,6 +901,20 @@ unit_wedge_alarm_preflight_refuses_empty_command_directive() { 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) @@ -1052,6 +1066,7 @@ 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