From aac3b7f7f2ed47a0b465670783e5269f6cf31f5e Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Mon, 31 Aug 2026 08:21:30 +0800 Subject: [PATCH 1/4] fix: recover Claude away-mode delivery --- bin/fm-composer-lib.sh | 43 +++++++-- bin/fm-supervise-daemon.sh | 87 +++++++++++++++++-- docs/verification/runtime-backends.md | 4 +- docs/verification/supervision.md | 22 +++-- ...k-herdr-claude-busy-guard-live-e2e.test.sh | 84 +++++++++++++++--- tests/fm-composer-lib.test.sh | 70 +++++++++++++++ tests/fm-daemon.test.sh | 86 ++++++++++++++++++ 7 files changed, 365 insertions(+), 31 deletions(-) diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index ae52361e8d7..970a609153c 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -314,6 +314,8 @@ fm_composer_strip_ghost() { FM_DELIVERY_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop' FM_DELIVERY_CLAUDE_BUSY_REGEX_DEFAULT='esc to interrupt|…[[:space:]]+\([0-9]+[smh]' FM_DELIVERY_CLAUDE_CURRENT_FOOTER_REGEX='^[[:space:]]*(esc to interrupt|thinking\.\.\.[[:space:]]+esc to interrupt|[^[:space:]]+[[:space:]]+[^[:space:]]+…[[:space:]]+\([0-9]+[smh]([[:space:]]+[·•][^)]*)?\))[[:space:]]*$' +FM_DELIVERY_CLAUDE_ACTIVE_COMPOSER_REGEX='Press up to edit queued messages' +FM_DELIVERY_CLAUDE_ACTIVE_TOOL_REGEX='Running…[[:space:]]+\([0-9]+[smh].*timeout' FM_DELIVERY_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt' FM_DELIVERY_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt' FM_DELIVERY_PI_BUSY_REGEX_DEFAULT='Working\.\.\.' @@ -329,7 +331,8 @@ FM_DELIVERY_CURSOR_BUSY_REGEX_DEFAULT='ctrl\+c to stop' FM_DELIVERY_KIMI_BUSY_REGEX_DEFAULT='^[[:space:]]*(🌑|🌒|🌓|🌔|🌕|🌖|🌗|🌘)[[:space:]]+·[[:space:]]+' fm_busy_lines_match() { # [harness] - local harness=${1:-} lines regex + local harness=${1:-} lines regex matched + FM_BUSY_MATCHED_ROW= IFS= read -r -d '' lines || true if [ -n "${FM_BUSY_REGEX:-}" ]; then regex=$FM_BUSY_REGEX @@ -350,13 +353,18 @@ fm_busy_lines_match() { # [harness] ;; esac fi - [ -n "$regex" ] && printf '%s' "$lines" | grep -qiE "$regex" + [ -n "$regex" ] || return 1 + matched=$(printf '%s' "$lines" | grep -iE "$regex" | tail -1) + [ -n "$matched" ] || return 1 + FM_BUSY_MATCHED_ROW=$matched + return 0 } # fm_claude_current_footer_busy returns 0 for busy, 1 for idle, and 2 for # unreadable or structurally ambiguous state. fm_claude_current_footer_busy() { - local lines plain footer footer_row composer caps verdict + local lines plain footer footer_row composer caps verdict active_rows preceding screen_caps screen_verdict + FM_CLAUDE_BUSY_MATCHED_ROW= IFS= read -r -d '' lines || true [ -n "$lines" ] || return 2 plain=$(printf '%s' "$lines" | fm_composer_strip_ansi) || return 2 @@ -369,17 +377,42 @@ fm_claude_current_footer_busy() { NF { last=NR } END { for (row=1; row < last; row++) print rows[row] } ') + _fm_composer_scan_screen "$composer" '' + _fm_composer_select_cursorless "$composer" || return 2 + screen_caps=$(printf '%s\n' 'styled=1' 'cursor=0' 'identity=0' 'rows=12') + screen_verdict=$(fm_composer_classify_screen "$screen_caps" "$lines") + case "$screen_verdict" in + pending|pending-unproven) return 1 ;; + esac + active_rows=$(printf '%s\n' "$composer" | awk \ + -v first="$FM_COMPOSER_SELECTED_FIRST" -v last="$FM_COMPOSER_SELECTED_LAST" \ + 'NR - 1 >= first && NR - 1 <= last { print }') + preceding=$(printf '%s\n' "$composer" | awk \ + -v first="$FM_COMPOSER_SELECTED_FIRST" 'NR <= first { print }' \ + | grep -v '^[[:space:]]*$' | tail -8) + if [ "$screen_verdict" = empty ] \ + && { printf '%s\n' "$active_rows" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_COMPOSER_REGEX" \ + || { printf '%s\n' "$preceding" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_TOOL_REGEX" \ + && printf '%s\n' "$preceding" | grep -Fq '(ctrl+b to run in background)'; }; }; then + if fm_busy_lines_match claude <<< "$preceding"; then + # shellcheck disable=SC2034 # Output read by sourcing callers after this function returns. + FM_CLAUDE_BUSY_MATCHED_ROW=${FM_BUSY_MATCHED_ROW:-unknown} + return 0 + fi + return 2 + fi caps=$(printf '%s\n' 'styled=0' 'cursor=0' 'identity=0' 'rows=12') verdict=$(fm_composer_classify_screen "$caps" "$composer") [ "$verdict" = empty ] || return 2 - _fm_composer_scan_screen "$composer" '' - _fm_composer_select_cursorless "$composer" || return 2 [ "$footer_row" -eq $((FM_COMPOSER_SELECTED_BOUNDARY + 1)) ] || return 2 if [ -n "${FM_BUSY_REGEX:-}" ]; then if printf '%s\n' "$footer" | fm_busy_lines_match claude; then + FM_CLAUDE_BUSY_MATCHED_ROW=$footer return 0 fi elif printf '%s\n' "$footer" | grep -qE "$FM_DELIVERY_CLAUDE_CURRENT_FOOTER_REGEX"; then + # shellcheck disable=SC2034 # Output read by sourcing callers after this function returns. + FM_CLAUDE_BUSY_MATCHED_ROW=$footer return 0 fi return 1 diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index f53865033a9..722072a3191 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -117,6 +117,11 @@ # absent file/var means auto: on macOS that is # an OS-level notification, so the alarm is # never silent. See wedge_alarm_notify below +# FM_RENDERED_BUSY_RECOVERY_POLLS +# identical Claude rendered-busy polls needed +# after a max-defer alarm before the daemon may +# re-check an elapsed footer against an empty +# composer (default 3) # and docs/configuration.md. # FM_WEDGE_ALARM_EXEC notifier seam: when set, every notifier # channel routes through this command as @@ -204,9 +209,12 @@ HOUSEKEEPING_TICK_DEFAULT=15 # the normal flush path and, if that cannot confirm a submit, raises a loud wedge # alarm. The escape hatch makes a guard false-positive visible instead of silent. MAX_DEFER_SECS_DEFAULT=300 +RENDERED_BUSY_RECOVERY_POLLS_DEFAULT=3 WEDGE_ALARM_TIMEOUT_SECS_DEFAULT=10 WEDGE_ALARM_LAST_EPOCH=0 WEDGE_ALARM_NOTIFIER_PID= +FM_RENDERED_BUSY_LAST_ROW= +FM_RENDERED_BUSY_STREAK=0 # The captain-relevant verb set and the status classifiers (last_status_line, # status_is_captain_relevant, window_to_task, scan_captain_relevant_statuses) now # live in bin/fm-classify-lib.sh, shared with the always-on watcher. @@ -599,6 +607,7 @@ pane_is_busy() { # [backend] local target=$1 backend=${2:-tmux} native tail40 visible harness claude_footer_rc FM_PANE_BUSY_REASON= FM_PANE_NATIVE_BUSY_STATE= + FM_PANE_BUSY_MATCHED_ROW= fm_daemon_primary_harness >/dev/null harness=${FM_DAEMON_PRIMARY_HARNESS:-unknown} native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null) @@ -621,8 +630,9 @@ pane_is_busy() { # [backend] FM_PANE_BUSY_REASON='unreadable' return 1 } - if printf '%s' "$visible" | fm_claude_current_footer_busy; then + if fm_claude_current_footer_busy <<< "$visible"; then FM_PANE_BUSY_REASON='rendered-busy' + FM_PANE_BUSY_MATCHED_ROW=${FM_CLAUDE_BUSY_MATCHED_ROW:-unknown} return 0 else claude_footer_rc=$? @@ -642,11 +652,55 @@ pane_is_busy() { # [backend] if printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ | fm_busy_lines_match "$harness"; then FM_PANE_BUSY_REASON='rendered-busy' + visible=$(printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12) + if fm_busy_lines_match "$harness" <<< "$visible"; then + FM_PANE_BUSY_MATCHED_ROW=${FM_BUSY_MATCHED_ROW:-unknown} + fi return 0 fi return 1 } +rendered_busy_recovery_ready() { # + local state=$1 backend=$2 harness=$3 native_state=$4 matched_row=$5 polls terminal_record composer + FM_RENDERED_BUSY_RECOVERY_COMPOSER= + [ "$backend" = herdr ] && [ "$harness" = claude ] || return 1 + [ -s "$state/.subsuper-inject-wedged" ] || { + FM_RENDERED_BUSY_LAST_ROW= + FM_RENDERED_BUSY_STREAK=0 + return 1 + } + # A static bare `esc to interrupt` footer can belong to a real long-running + # turn. Recovery is limited to a byte-stable row carrying a frozen elapsed + # value, the exact stale-transcript shape from incident B. + printf '%s\n' "$matched_row" | grep -qE '\([0-9]+[smh]([[:space:]·•]|\))' || { + FM_RENDERED_BUSY_LAST_ROW= + FM_RENDERED_BUSY_STREAK=0 + return 1 + } + if [ "$native_state" = working ]; then + terminal_record=$(cat "$state/.afk-daemon-terminal" 2>/dev/null || true) + [ "$terminal_record" = $'none\t-\tnative' ] || { + FM_RENDERED_BUSY_LAST_ROW= + FM_RENDERED_BUSY_STREAK=0 + return 1 + } + fi + polls=${FM_RENDERED_BUSY_RECOVERY_POLLS:-$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT} + case "$polls" in ''|*[!0-9]*|0) polls=$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT ;; esac + if [ "$matched_row" = "$FM_RENDERED_BUSY_LAST_ROW" ]; then + FM_RENDERED_BUSY_STREAK=$((FM_RENDERED_BUSY_STREAK + 1)) + else + FM_RENDERED_BUSY_LAST_ROW=$matched_row + FM_RENDERED_BUSY_STREAK=1 + fi + [ "$FM_RENDERED_BUSY_STREAK" -ge "$polls" ] || return 1 + composer=$(fm_backend_composer_state "$backend" "${FM_SUPERVISOR_TARGET:-$FM_SUPERVISOR_TARGET_DEFAULT}" 2>/dev/null) + FM_RENDERED_BUSY_RECOVERY_COMPOSER=${composer:-unknown} + [ "$composer" = empty ] || return 1 + return 0 +} + # pane_input_pending dispatches through fm_backend_composer_state and treats # every verdict except exact empty as unsafe. inject_msg reads the full verdict # directly and applies the same positive-proof boundary. @@ -1348,7 +1402,7 @@ window_for_task() { # [state] # line, or a previous injection's unsent text), defer entirely - injecting # would merge with the human's text. inject_msg() { # [state] - local msg=$1 state target backend harness retries sleep_s verdict composer encoded native_state busy_rc + local msg=$1 state target backend harness retries sleep_s verdict composer encoded native_state busy_rc matched_row recovery_polls state="${2:-$(_state_root)}" # (1) Presence-gate: inject ONLY when afk is active. When afk is off, the # daemon self-handles and stays quiet; firstmate drives the normal always-on @@ -1378,15 +1432,36 @@ inject_msg() { # [state] native_state=${FM_PANE_NATIVE_BUSY_STATE:-unknown} if [ "$busy_rc" -eq 0 ] || [ "${FM_PANE_BUSY_REASON:-}" = unreadable ]; then case "${FM_PANE_BUSY_REASON:-native-busy}" in - native-busy|rendered-busy) + rendered-busy) + matched_row=${FM_PANE_BUSY_MATCHED_ROW:-unknown} + harness=$(fm_daemon_primary_harness) + if rendered_busy_recovery_ready "$state" "$backend" "$harness" "$native_state" "$matched_row"; then + recovery_polls=${FM_RENDERED_BUSY_RECOVERY_POLLS:-$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT} + composer=empty + log "inject recovery: alarm-fired stable rendered-busy row for ${recovery_polls} polls; native-state=$native_state; composer=empty; matched-row=$matched_row" + FM_RENDERED_BUSY_LAST_ROW= + FM_RENDERED_BUSY_STREAK=0 + else + if [ -n "${FM_RENDERED_BUSY_RECOVERY_COMPOSER:-}" ]; then + log "inject deferred: supervisor composer not confirmed-empty (state=${FM_RENDERED_BUSY_RECOVERY_COMPOSER}: pending input, dead-shell prompt, or unreadable pane; subcause=composer=${FM_RENDERED_BUSY_RECOVERY_COMPOSER}; recovery-from=rendered-busy; native-state=$native_state; matched-row=$matched_row)" + else + log "inject deferred: supervisor pane busy (agent mid-turn; subcause=rendered-busy; native-state=$native_state; matched-row=$matched_row)" + fi + return 1 + fi + ;; + native-busy) log "inject deferred: supervisor pane busy (agent mid-turn; subcause=${FM_PANE_BUSY_REASON:-native-busy}; native-state=$native_state)" + return 1 ;; *) log "inject deferred: supervisor pane unreadable (subcause=${FM_PANE_BUSY_REASON:-unknown}; native-state=$native_state)" + return 1 ;; esac - return 1 fi + FM_RENDERED_BUSY_LAST_ROW= + FM_RENDERED_BUSY_STREAK=0 # b) Composer-guard: inject ONLY into a confirmed-empty GENUINE agent # composer. The shared classifier (fm_backend_composer_state -> # fm_composer_classify_content, bin/fm-composer-lib.sh) reports 'pending' @@ -1396,7 +1471,9 @@ inject_msg() { # [state] # target - typing the escalation into a shell could execute it - so defer # on anything that is not affirmatively 'empty'. A deferred escalation # stays buffered for the next cycle or the catch-up flush. - composer=$(fm_backend_composer_state "$backend" "$target" 2>/dev/null) + if [ "${composer:-}" != empty ]; then + composer=$(fm_backend_composer_state "$backend" "$target" 2>/dev/null) + fi if [ "$composer" != empty ]; then log "inject deferred: supervisor composer not confirmed-empty (state=${composer:-unknown}: pending input, dead-shell prompt, or unreadable pane; subcause=composer=${composer:-unknown}; native-state=$native_state)" return 1 diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 8122bf2fcf7..e8280910bd0 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -640,7 +640,9 @@ FM_AFK_PI_HERDR_E2E=1 HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ Observed guarantees: pending composer input refused injection and raised one alert; idle Pi accepted one marked escalation; the return gate refused ordinary work while a live blocker remained; resolving the blocker allowed the return flow. The dedicated Herdr daemon workspace topology is covered by `tests/fm-afk-launch.test.sh` and preserves the captain tab's pane count. -The 2026-08-28 Herdr 0.8.2 plus Claude Code 2.1.248 away-mode result is recorded in [supervision verification](supervision.md#herdrclaude-away-mode-busy-guard-2026-08-28), and the live guard is refreshed with `FM_AFK_HERDR_CLAUDE_LIVE=1 tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh`. +The 2026-08-31 Herdr 0.8.2 plus Claude Code 2.1.251 away-mode result is recorded in [supervision verification](supervision.md#herdrclaude-away-mode-busy-guard-2026-08-31). +The live guard is refreshed with `HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh FM_AFK_HERDR_CLAUDE_LIVE=1 tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh`. +It proves the broad idle-footer false positive, scoped idle delivery, repeated real foreground-turn deferral with exact spinner rows, and pending human-text preservation in one named non-default lab session. ## Zellij diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 40565f35c67..c76e047417c 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -205,16 +205,24 @@ tests/fm-busy-adapter-wiring.test.sh tests/fm-crew-state.test.sh ``` -### Herdr+Claude away-mode busy guard, 2026-08-28 +### Herdr+Claude away-mode busy guard, 2026-08-31 -The final Herdr-lab regression passed with Herdr 0.8.2 and Claude Code 2.1.248. -Native `agent_status=working` with a rendered-idle Claude pane and an `empty` composer delivered one queued escalation and cleared the buffer, a genuine foreground turn produced a `rendered-busy` deferral carrying `native-state=working`, and bright human composer text remained pending and unchanged with a `composer=pending` deferral. -The colocated unit suites in `tests/fm-daemon.test.sh` and `tests/fm-backend-herdr.test.sh` also prove that Claude native working plus rendered-idle pending text is not confirmed, accept rendered active-turn proof, hard-defer unreadable capture, and preserve the `native-busy` fast path for every non-Herdr+Claude combination. +The final guarded Herdr-lab regression passed on 2026-08-31 with Herdr 0.8.2 and Claude Code 2.1.251. +Native `agent_status=working` with an idle Claude pane and an `empty` composer delivered one queued escalation and cleared the buffer exactly once. +The idle status footer visibly carried `1 shell · esc to interrupt`, so the historical broad matcher returned busy while the scoped matcher correctly returned idle. +A real 150-second foreground Bash turn then produced repeated `rendered-busy` daemon deferrals carrying `native-state=working` and exact changing spinner rows such as `✽ Enchanting… (11s · ↓ 128 tokens)`. +After the turn was interrupted, bright human composer text remained pending and unchanged with a `composer=pending` deferral. +The colocated unit suites in `tests/fm-daemon.test.sh`, `tests/fm-backend-herdr.test.sh`, and `tests/fm-composer-lib.test.sh` pin the idle shell footer, agent-count and update footers, dim and dark-truecolor ghosts, real active-tool plus spinner context, exact matched-row logging, and the alarmed byte-stable false-busy recovery. ```sh -FM_AFK_HERDR_CLAUDE_LIVE=1 tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh -# ok - real Herdr 0.8.2 + Claude 2.1.248 (Claude Code): native working with rendered-idle empty composer submits once -# ok - real Herdr 0.8.2 + Claude 2.1.248 (Claude Code): rendered-busy and pending-composer deferrals preserve human text +HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh \ + FM_AFK_HERDR_CLAUDE_LIVE=1 \ + tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh +# verdict: idle-post-afk agent_status=working composer=empty pane_is_busy_rc=1 broad_match_rc=0 scoped_match_rc=1 subcause=idle native-state=working matched-row=none +# ok - real Herdr 0.8.2 + Claude 2.1.251 (Claude Code): native working with rendered-idle empty composer submits once +# verdict: active-foreground agent_status=working composer=empty pane_is_busy_rc=0 broad_match_rc=0 scoped_match_rc=0 subcause=rendered-busy native-state=working matched-row=· Moseying… (12s · ↓ 127 tokens) +# verdict: pending-human-text agent_status=idle composer=pending pane_is_busy_rc=1 broad_match_rc=1 scoped_match_rc=1 subcause=idle native-state=idle matched-row=none +# ok - real Herdr 0.8.2 + Claude 2.1.251 (Claude Code): rendered-busy and pending-composer deferrals preserve human text # evidence: native=working rendered=idle composer=empty delivered_once=1 rendered-busy=1 native-state=working=1 composer=pending=1 ``` diff --git a/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh b/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh index 57fc608efb4..b45d08827e3 100755 --- a/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh +++ b/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh @@ -29,7 +29,7 @@ done || fail "FM_AFK_HERDR_CLAUDE_LIVE=1 but the Herdr lab helper is not executable at $HERDR_LAB_HELPER" ORIGINAL_PATH=$PATH -HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-afk-herdr-claude-busy-guard-f1) \ +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-afk-inject-wedge-w2) \ || fail "could not generate the isolated Herdr lab session name" TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-afk-herdr-claude-guard.XXXXXX") \ || fail "could not create the live-test temporary root" @@ -128,8 +128,8 @@ composer_state() { rendered_claude_busy() { local capture capture=$(fm_backend_capture herdr "$TARGET" 40 2>/dev/null) || return 1 - printf '%s' "$capture" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match claude + capture=$(printf '%s' "$capture" | grep -v '^[[:space:]]*$' | tail -12) + fm_claude_current_footer_busy <<< "$capture" } claude_pane_is_busy() { @@ -211,6 +211,25 @@ screen_text() { lab pane read "$PANE" --source recent --lines 500 2>/dev/null || true } +screen_ansi() { + lab pane read "$PANE" --source recent --lines 500 --format ansi 2>/dev/null || true +} + +emit_verdict_evidence() { + local label=$1 busy_rc=1 broad_rc=1 scoped_rc=1 capture + claude_pane_is_busy && busy_rc=0 + capture=$(fm_backend_capture herdr "$TARGET" 40 2>/dev/null | grep -v '^[[:space:]]*$' | tail -12) + fm_busy_lines_match claude <<< "$capture" && broad_rc=0 + fm_claude_current_footer_busy <<< "$capture" && scoped_rc=0 + printf 'verdict: %s agent_status=%s composer=%s pane_is_busy_rc=%s broad_match_rc=%s scoped_match_rc=%s subcause=%s native-state=%s matched-row=%s\n' \ + "$label" "$(agent_status)" "$(composer_state)" "$busy_rc" "$broad_rc" "$scoped_rc" \ + "${FM_PANE_BUSY_REASON:-idle}" "${FM_PANE_NATIVE_BUSY_STATE:-unknown}" \ + "${FM_PANE_BUSY_MATCHED_ROW:-none}" + printf 'ansi-rows-begin: %s\n' "$label" + screen_ansi | tail -n 16 + printf 'ansi-rows-end: %s\n' "$label" +} + token_count() { local token=$1 screen screen=$(screen_text) @@ -299,6 +318,32 @@ wait_for_log_subcause() { return 1 } +wait_for_log_subcause_count() { + local subcause=$1 want=$2 count + for _ in $(seq 1 60); do + count=$(grep -F -c "subcause=$subcause" "$STATE_DIR/.supervise-daemon.log" 2>/dev/null || true) + [ "$count" -ge "$want" ] && return 0 + sleep 1 + done + return 1 +} + +wait_for_rendered_idle_with_pending() { + local human=$1 screen composer busy + for _ in $(seq 1 60); do + screen=$(screen_text) + composer=$(composer_state) + busy=1 + claude_pane_is_busy || busy=0 + if [ "$composer" = pending ] && [ "$busy" -eq 0 ] \ + && printf '%s\n' "$screen" | grep -Fq "$human"; then + return 0 + fi + sleep 1 + done + return 1 +} + wait_for_log_native_state_working() { for _ in $(seq 1 30); do if grep -Fq 'native-state=working' "$STATE_DIR/.supervise-daemon.log" 2>/dev/null; then @@ -325,6 +370,7 @@ if ! wait_for_afk_daemon; then fi wait_for_idle_native_working \ || fail "Herdr did not report working with an idle Claude composer after the foreground /afk turn" +emit_verdict_evidence idle-post-afk ESCALATION_ONE="FM_AFK_CLAUDE_GUARD_ONE_$$" printf 'done: %s https://example.test/afk-one\n' "$ESCALATION_ONE" > "$STATE_DIR/crew-one.status" @@ -344,24 +390,35 @@ fi pass "real Herdr $HERDR_VERSION + Claude $CLAUDE_VERSION: native working with rendered-idle empty composer submits once" FOREGROUND_TOKEN="FM_AFK_CLAUDE_GUARD_FOREGROUND_$$" -send_line "Use Bash to run python3 -c 'import time; time.sleep(12)' and then reply exactly $FOREGROUND_TOKEN and nothing else." \ +send_line "Use Bash to run python3 -c 'import time; time.sleep(150)' and then reply exactly $FOREGROUND_TOKEN and nothing else." \ || fail "could not start a genuine foreground Claude turn" -wait_for_rendered_busy "$FOREGROUND_TOKEN" \ - || fail "the genuine Claude foreground turn never exposed its rendered active-turn signature" - +if ! wait_for_rendered_busy "$FOREGROUND_TOKEN"; then + emit_verdict_evidence active-foreground-unmatched + echo "daemon log:" >&2 + sed -n '1,$p' "$STATE_DIR/.supervise-daemon.log" >&2 2>/dev/null || true + fail "the genuine Claude foreground turn never exposed its rendered active-turn signature" +fi +emit_verdict_evidence active-foreground-before-escalation ESCALATION_TWO="FM_AFK_CLAUDE_GUARD_TWO_$$" printf 'done: %s https://example.test/afk-two\n' "$ESCALATION_TWO" > "$STATE_DIR/crew-two.status" +if ! wait_for_log_subcause_count rendered-busy 2; then + emit_verdict_evidence active-foreground-missed-by-daemon + echo "daemon log:" >&2 + sed -n '1,$p' "$STATE_DIR/.supervise-daemon.log" >&2 2>/dev/null || true + fail "the active foreground turn did not produce two rendered-busy daemon polls" +fi +wait_for_log_native_state_working \ + || fail "the rendered-busy deferral did not record native-state=working" +emit_verdict_evidence active-foreground HUMAN_TEXT="bright-human-draft-$$" lab pane send-text "$PANE" "$HUMAN_TEXT" >/dev/null \ || fail "could not leave bright human text in the Claude composer" wait_for_human_pending "$HUMAN_TEXT" \ || fail "bright human text did not remain pending in the Claude composer" -wait_for_log_subcause rendered-busy \ - || fail "the active foreground deferral did not log subcause=rendered-busy" -wait_for_log_native_state_working \ - || fail "the rendered-busy deferral did not record native-state=working" -wait_for_foreground_done_with_pending "$FOREGROUND_TOKEN" "$HUMAN_TEXT" \ - || fail "after the foreground turn, Claude did not settle with pending human text and a rendered-idle pane" +lab pane send-keys "$PANE" escape >/dev/null \ + || fail "could not interrupt the deliberately long foreground turn" +wait_for_rendered_idle_with_pending "$HUMAN_TEXT" \ + || fail "after interrupting the foreground turn, Claude did not settle with pending human text and a rendered-idle pane" [ "$(token_count "$ESCALATION_TWO")" -eq 0 ] \ || fail "the second escalation was injected into the bright human composer" @@ -376,6 +433,7 @@ if [ ! -s "$STATE_DIR/.subsuper-escalations" ]; then fi wait_for_log_subcause composer=pending \ || fail "the pending-composer deferral did not log subcause=composer=pending" +emit_verdict_evidence pending-human-text screen=$(screen_text) printf '%s\n' "$screen" | grep -Fq "$HUMAN_TEXT" \ || fail "bright human text was modified or disappeared while the daemon deferred" diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 0b1c3433307..1a759db31cc 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -692,6 +692,76 @@ test_claude_current_footer_requires_selected_composer_adjacency() { test_claude_current_footer_requires_selected_composer_adjacency +test_claude_incident_b_footer_matrix_stays_idle() { + local base screen counterfactual rc + base=$'tool output:\n• Working (4s • esc to interrupt)\n────────────────────────\n❯\n────────────────────────' + for screen in \ + "$base"$'\n ⏵⏵ bypass permissions on (shift+tab to cycle)' \ + "$base"$'\n ⏵⏵ bypass permissions on · ← 1 agent' \ + "$base"$'\n ✔ Update installed · Restart to update' \ + "$base"$'\n\033[2m Try “write tests”\033[0m' \ + "$base"$'\n\033[38;2;72;72;72m Try “write tests”\033[39m'; do + if printf '%s' "$screen" | fm_claude_current_footer_busy; then + fail "incident-B nested busy text plus an idle Claude footer must not read busy" + else + rc=$? + fi + [ "$rc" -ne 0 ] \ + || fail "incident-B footer fixture unexpectedly returned busy" + done + + printf '%s' "$base" | fm_busy_lines_match claude \ + || fail "the pre-#94 broad matcher must reproduce the incident-B false positive" + counterfactual=${base/esc to interrupt/turn finished} + if printf '%s' "$counterfactual" | fm_busy_lines_match claude; then + fail "removing only the incident-B esc-to-interrupt token must flip the broad matcher idle" + fi + + printf '%s' $'────────────────────────\n❯\n────────────────────────\nesc to interrupt' \ + | fm_claude_current_footer_busy \ + || fail "a genuine current Claude esc-to-interrupt footer must remain busy" + printf '%s' $'────────────────────────\n❯\n────────────────────────\n✲ Pollinating… (16s · ↓ 1.1k tokens)' \ + | fm_claude_current_footer_busy \ + || fail "a genuine current Claude spinner footer must remain busy" + pass "fm_claude_current_footer_busy: incident-B footer and ghost variants stay idle while genuine current turns stay busy" +} + +test_claude_incident_b_footer_matrix_stays_idle + +test_claude_active_spinner_pairs_with_active_composer() { + local active idle pending rc + active=$'tool output:\n ⏺ Running… (43s · timeout 3m 20s)\n (ctrl+b to run in background)\n\n✶ Jitterbugging… (45s · ↓ 127 tokens)\n ⎿ Tip: Send messages to Claude while it works\n\n────────────────────────\n❯ \033[2mPress up to edit queued messages\033[0m\n────────────────────────\n ⏵⏵ bypass permissions on · 1 shell · esc to interrupt · ← 1 agent · ↓ to manage' + fm_claude_current_footer_busy <<< "$active" \ + || fail "a real Claude spinner paired with the active composer hint must read busy" + [ "$FM_CLAUDE_BUSY_MATCHED_ROW" = '✶ Jitterbugging… (45s · ↓ 127 tokens)' ] \ + || fail "the active Claude verdict did not expose its exact matched spinner row: ${FM_CLAUDE_BUSY_MATCHED_ROW:-unset}" + + idle=${active/Press up to edit queued messages/} + idle=${idle/Running… (43s · timeout 3m 20s)/Running in the background (↓ to manage)} + idle=${idle/(ctrl+b to run in background)/} + if fm_claude_current_footer_busy <<< "$idle"; then + fail "the same stale spinner above a bare idle composer must not read busy" + else + rc=$? + fi + [ "$rc" -eq 1 ] \ + || fail "a bare idle composer with the shell footer should read idle, got rc=$rc" + printf '%s' "$idle" | fm_busy_lines_match claude \ + || fail "the incident-B broad matcher must still reproduce on the idle shell footer" + + pending=${active/$'\033[2mPress up to edit queued messages\033[0m'/bright-human-draft} + if fm_claude_current_footer_busy <<< "$pending"; then + fail "stale active-tool rows must not outrank bright pending composer text" + else + rc=$? + fi + [ "$rc" -eq 1 ] \ + || fail "bright pending composer text must route to the composer guard, got rc=$rc" + pass "fm_claude_current_footer_busy: active context distinguishes a live turn while idle and pending composers stay injectable-safe" +} + +test_claude_active_spinner_pairs_with_active_composer + test_submit_retry_reports_send_failed_before_any_enter() { local out submit_key_always_fails() { return 1; } diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index ae8d47134dc..004d9dbeafc 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -2371,6 +2371,90 @@ test_inject_msg_ignores_nested_claude_busy_text_above_idle_composer() { pass "inject_msg: nested worker busy text cannot impersonate the current Claude active footer" } +test_inject_msg_recovers_stable_rendered_false_busy_after_alarm() { + local dir state sent attempt + dir=$(make_supercase inject-stable-rendered-recovery) + state="$dir/state" + sent="$dir/sent" + afk_enter "$state" + printf 'none\t-\tnative\n' > "$state/.afk-daemon-terminal" + printf '%s\n' 'alarm already fired' > "$state/.subsuper-inject-wedged" + ( + fm_backend_target_exists() { return 0; } + pane_is_busy() { + FM_PANE_BUSY_REASON=rendered-busy + FM_PANE_NATIVE_BUSY_STATE=working + FM_PANE_BUSY_MATCHED_ROW='• Working (4s • esc to interrupt)' + return 0 + } + fm_backend_composer_state() { printf 'empty'; } + fm_backend_send_text_submit() { printf '%s\n' "$1" >> "$sent"; printf 'empty'; } + FM_DAEMON_PRIMARY_HARNESS=claude + FM_RENDERED_BUSY_RECOVERY_POLLS=3 + LOG="$dir/daemon.log" + FM_SUPERVISOR_BACKEND=herdr + FM_SUPERVISOR_TARGET="default:w1:p2" + for attempt in 1 2; do + if inject_msg "hello" "$state"; then + fail "stable rendered busy recovered before the configured poll threshold on attempt $attempt" + fi + done + inject_msg "hello" "$state" \ + || fail "stable rendered false busy did not recover after the alarm and configured poll threshold" + ) || fail "stable rendered-busy recovery subshell failed" + [ "$(wc -l < "$sent" 2>/dev/null || echo 0)" -eq 1 ] \ + || fail "stable rendered-busy recovery submitted more or less than once" + grep -F 'inject recovery: alarm-fired stable rendered-busy row for 3 polls; native-state=working; composer=empty; matched-row=• Working (4s • esc to interrupt)' "$dir/daemon.log" >/dev/null \ + || fail "stable rendered-busy recovery did not log its exact proof: $(cat "$dir/daemon.log")" + pass "inject_msg: an alarmed byte-stable stale Claude row recovers once through an affirmatively empty composer" +} + +test_inject_msg_rendered_recovery_stays_fail_safe() { + local dir state sent attempt + dir=$(make_supercase inject-rendered-recovery-fail-safe) + state="$dir/state" + sent="$dir/sent" + afk_enter "$state" + printf 'none\t-\tnative\n' > "$state/.afk-daemon-terminal" + printf '%s\n' 'alarm already fired' > "$state/.subsuper-inject-wedged" + ( + fm_backend_target_exists() { return 0; } + pane_is_busy() { + FM_PANE_BUSY_REASON=rendered-busy + FM_PANE_NATIVE_BUSY_STATE=working + FM_PANE_BUSY_MATCHED_ROW='esc to interrupt' + return 0 + } + fm_backend_composer_state() { printf 'pending'; } + fm_backend_send_text_submit() { printf '%s\n' "$1" >> "$sent"; printf 'empty'; } + FM_DAEMON_PRIMARY_HARNESS=claude + FM_RENDERED_BUSY_RECOVERY_POLLS=2 + LOG="$dir/daemon.log" + FM_SUPERVISOR_BACKEND=herdr + FM_SUPERVISOR_TARGET="default:w1:p2" + for attempt in 1 2 3; do + if inject_msg "hello" "$state"; then + fail "a static esc-to-interrupt footer without an elapsed token recovered" + fi + done + pane_is_busy() { + FM_PANE_BUSY_REASON=rendered-busy + FM_PANE_NATIVE_BUSY_STATE=working + FM_PANE_BUSY_MATCHED_ROW='• Working (4s • esc to interrupt)' + return 0 + } + for attempt in 1 2 3; do + if inject_msg "hello" "$state"; then + fail "a pending composer recovered from rendered busy" + fi + done + ) || fail "rendered-busy fail-safe recovery subshell failed" + [ ! -s "$sent" ] || fail "rendered-busy recovery typed into an unsafe composer" + grep -F 'subcause=composer=pending' "$dir/daemon.log" >/dev/null \ + || fail "post-alarm pending composer did not log its fail-safe verdict: $(cat "$dir/daemon.log")" + pass "inject_msg: post-alarm recovery never overrides a static live footer or a pending composer" +} + test_inject_msg_herdr_claude_unreadable_capture_defers() { local dir state dir=$(make_supercase inject-herdr-claude-unreadable) @@ -2661,6 +2745,8 @@ test_pane_is_busy_herdr_claude_native_idle_keeps_rendered_guard test_pane_is_busy_native_busy_fast_path_outside_herdr_claude test_inject_msg_logs_native_busy_subcause test_inject_msg_logs_rendered_busy_subcause +test_inject_msg_recovers_stable_rendered_false_busy_after_alarm +test_inject_msg_rendered_recovery_stays_fail_safe test_inject_msg_ignores_nested_claude_busy_text_above_idle_composer test_inject_msg_herdr_claude_unreadable_capture_defers test_primary_busy_guard_is_harness_scoped From 0239494f033ff42ee74189d72d1004613c6aa357 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Mon, 31 Aug 2026 09:16:32 +0800 Subject: [PATCH 2/4] no-mistakes(review): Harden Claude away-mode capture and recovery guards --- bin/backends/herdr.sh | 13 +++-- bin/fm-composer-lib.sh | 24 +++++---- bin/fm-supervise-daemon.sh | 54 ++++++++++++------- docs/verification/supervision.md | 7 ++- ...k-herdr-claude-busy-guard-live-e2e.test.sh | 28 +++++++--- tests/fm-composer-lib.test.sh | 35 ++++++++---- tests/fm-daemon.test.sh | 53 ++++++++++++++++++ 7 files changed, 164 insertions(+), 50 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index d6ebb710073..333b50af36d 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2673,12 +2673,19 @@ fm_backend_herdr_composer_state() { # -> empty|pending|pending-unprove # shared matcher uses its union of verified tokens, which is what the submit # core wants: it has no recorded harness for the pane. fm_backend_herdr_rendered_busy_state() { # [harness] -> busy|idle|unknown - local target=$1 harness=${2:-} cap visible - cap=$(fm_backend_herdr_capture "$target" 40) || { printf 'unknown'; return 0; } + local target=$1 harness=${2:-} cap visible caps + if cap=$(fm_backend_herdr_capture_ansi "$target" 40 2>/dev/null) && [ -n "$cap" ]; then + caps=$'styled=1\ncursor=0\nidentity=0\nrows=12' + elif cap=$(fm_backend_herdr_capture "$target" 40); then + caps=$'styled=0\ncursor=0\nidentity=0\nrows=12' + else + printf 'unknown' + return 0 + fi visible=$(printf '%s' "$cap" | grep -v '^[[:space:]]*$' | tail -12) [ -n "$visible" ] || { printf 'unknown'; return 0; } if [ "$harness" = claude ]; then - if printf '%s' "$visible" | fm_claude_current_footer_busy; then + if printf '%s' "$visible" | fm_claude_current_footer_busy "$caps"; then printf 'busy' else case "$?" in diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 970a609153c..0961be24494 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -363,8 +363,10 @@ fm_busy_lines_match() { # [harness] # fm_claude_current_footer_busy returns 0 for busy, 1 for idle, and 2 for # unreadable or structurally ambiguous state. fm_claude_current_footer_busy() { - local lines plain footer footer_row composer caps verdict active_rows preceding screen_caps screen_verdict + local capture_caps=${1:-} lines plain footer footer_row composer caps verdict active_rows preceding screen_verdict + local active_hint=0 active_tool=0 FM_CLAUDE_BUSY_MATCHED_ROW= + [ -n "$capture_caps" ] || capture_caps=$'styled=0\ncursor=0\nidentity=0\nrows=12' IFS= read -r -d '' lines || true [ -n "$lines" ] || return 2 plain=$(printf '%s' "$lines" | fm_composer_strip_ansi) || return 2 @@ -379,21 +381,20 @@ fm_claude_current_footer_busy() { ') _fm_composer_scan_screen "$composer" '' _fm_composer_select_cursorless "$composer" || return 2 - screen_caps=$(printf '%s\n' 'styled=1' 'cursor=0' 'identity=0' 'rows=12') - screen_verdict=$(fm_composer_classify_screen "$screen_caps" "$lines") - case "$screen_verdict" in - pending|pending-unproven) return 1 ;; - esac + screen_verdict=$(fm_composer_classify_screen "$capture_caps" "$lines") active_rows=$(printf '%s\n' "$composer" | awk \ -v first="$FM_COMPOSER_SELECTED_FIRST" -v last="$FM_COMPOSER_SELECTED_LAST" \ 'NR - 1 >= first && NR - 1 <= last { print }') preceding=$(printf '%s\n' "$composer" | awk \ -v first="$FM_COMPOSER_SELECTED_FIRST" 'NR <= first { print }' \ | grep -v '^[[:space:]]*$' | tail -8) - if [ "$screen_verdict" = empty ] \ - && { printf '%s\n' "$active_rows" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_COMPOSER_REGEX" \ - || { printf '%s\n' "$preceding" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_TOOL_REGEX" \ - && printf '%s\n' "$preceding" | grep -Fq '(ctrl+b to run in background)'; }; }; then + printf '%s\n' "$active_rows" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_COMPOSER_REGEX" && active_hint=1 + printf '%s\n' "$preceding" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_TOOL_REGEX" && active_tool=1 + if { [ "$screen_verdict" = empty ] \ + || { [ "$screen_verdict" = pending ] && [ "$active_hint" = 1 ]; }; } \ + && { [ "$active_hint" = 1 ] \ + || { [ "$active_tool" = 1 ] \ + && printf '%s\n' "$preceding" | grep -Fq '(ctrl+b to run in background)'; }; }; then if fm_busy_lines_match claude <<< "$preceding"; then # shellcheck disable=SC2034 # Output read by sourcing callers after this function returns. FM_CLAUDE_BUSY_MATCHED_ROW=${FM_BUSY_MATCHED_ROW:-unknown} @@ -401,6 +402,9 @@ fm_claude_current_footer_busy() { fi return 2 fi + case "$screen_verdict" in + pending|pending-unproven) return 1 ;; + esac caps=$(printf '%s\n' 'styled=0' 'cursor=0' 'identity=0' 'rows=12') verdict=$(fm_composer_classify_screen "$caps" "$composer") [ "$verdict" = empty ] || return 2 diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 722072a3191..02f785d4995 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -215,6 +215,7 @@ WEDGE_ALARM_LAST_EPOCH=0 WEDGE_ALARM_NOTIFIER_PID= FM_RENDERED_BUSY_LAST_ROW= FM_RENDERED_BUSY_STREAK=0 +FM_RENDERED_BUSY_RECOVERY_SUBCAUSE= # The captain-relevant verb set and the status classifiers (last_status_line, # status_is_captain_relevant, window_to_task, scan_captain_relevant_statuses) now # live in bin/fm-classify-lib.sh, shared with the always-on watcher. @@ -604,7 +605,7 @@ fm_daemon_primary_harness() { } pane_is_busy() { # [backend] - local target=$1 backend=${2:-tmux} native tail40 visible harness claude_footer_rc + local target=$1 backend=${2:-tmux} native tail40 visible harness claude_footer_rc claude_capture_caps FM_PANE_BUSY_REASON= FM_PANE_NATIVE_BUSY_STATE= FM_PANE_BUSY_MATCHED_ROW= @@ -621,16 +622,23 @@ pane_is_busy() { # [backend] # Herdr's native working state includes Claude's tracked away daemon shell. # For this pair, native state is diagnostic only and the rendered Claude # active-turn signature is the positive foreground-busy proof. - tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || { - FM_PANE_BUSY_REASON='unreadable' - return 1 - } + if declare -F fm_backend_herdr_capture_ansi >/dev/null 2>&1 \ + && tail40=$(fm_backend_herdr_capture_ansi "$target" 40 2>/dev/null) \ + && [ -n "$tail40" ]; then + claude_capture_caps=$'styled=1\ncursor=0\nidentity=0\nrows=12' + else + claude_capture_caps=$'styled=0\ncursor=0\nidentity=0\nrows=12' + tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || { + FM_PANE_BUSY_REASON='unreadable' + return 1 + } + fi visible=$(printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12) [ -n "$visible" ] || { FM_PANE_BUSY_REASON='unreadable' return 1 } - if fm_claude_current_footer_busy <<< "$visible"; then + if fm_claude_current_footer_busy "$claude_capture_caps" <<< "$visible"; then FM_PANE_BUSY_REASON='rendered-busy' FM_PANE_BUSY_MATCHED_ROW=${FM_CLAUDE_BUSY_MATCHED_ROW:-unknown} return 0 @@ -664,28 +672,36 @@ pane_is_busy() { # [backend] rendered_busy_recovery_ready() { # local state=$1 backend=$2 harness=$3 native_state=$4 matched_row=$5 polls terminal_record composer FM_RENDERED_BUSY_RECOVERY_COMPOSER= + FM_RENDERED_BUSY_RECOVERY_SUBCAUSE= [ "$backend" = herdr ] && [ "$harness" = claude ] || return 1 [ -s "$state/.subsuper-inject-wedged" ] || { FM_RENDERED_BUSY_LAST_ROW= FM_RENDERED_BUSY_STREAK=0 return 1 } - # A static bare `esc to interrupt` footer can belong to a real long-running - # turn. Recovery is limited to a byte-stable row carrying a frozen elapsed - # value, the exact stale-transcript shape from incident B. + case "$native_state" in + working) + terminal_record=$(cat "$state/.afk-daemon-terminal" 2>/dev/null || true) + [ "$terminal_record" = $'none\t-\tnative' ] || { + FM_RENDERED_BUSY_LAST_ROW= + FM_RENDERED_BUSY_STREAK=0 + return 1 + } + ;; + idle|done) + ;; + *) + FM_RENDERED_BUSY_RECOVERY_SUBCAUSE=native-unknown + FM_RENDERED_BUSY_LAST_ROW= + FM_RENDERED_BUSY_STREAK=0 + return 1 + ;; + esac printf '%s\n' "$matched_row" | grep -qE '\([0-9]+[smh]([[:space:]·•]|\))' || { FM_RENDERED_BUSY_LAST_ROW= FM_RENDERED_BUSY_STREAK=0 return 1 } - if [ "$native_state" = working ]; then - terminal_record=$(cat "$state/.afk-daemon-terminal" 2>/dev/null || true) - [ "$terminal_record" = $'none\t-\tnative' ] || { - FM_RENDERED_BUSY_LAST_ROW= - FM_RENDERED_BUSY_STREAK=0 - return 1 - } - fi polls=${FM_RENDERED_BUSY_RECOVERY_POLLS:-$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT} case "$polls" in ''|*[!0-9]*|0) polls=$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT ;; esac if [ "$matched_row" = "$FM_RENDERED_BUSY_LAST_ROW" ]; then @@ -1442,7 +1458,9 @@ inject_msg() { # [state] FM_RENDERED_BUSY_LAST_ROW= FM_RENDERED_BUSY_STREAK=0 else - if [ -n "${FM_RENDERED_BUSY_RECOVERY_COMPOSER:-}" ]; then + if [ -n "${FM_RENDERED_BUSY_RECOVERY_SUBCAUSE:-}" ]; then + log "inject deferred: supervisor pane busy (native state not proven safe; subcause=${FM_RENDERED_BUSY_RECOVERY_SUBCAUSE}; recovery-from=rendered-busy; native-state=$native_state; matched-row=$matched_row)" + elif [ -n "${FM_RENDERED_BUSY_RECOVERY_COMPOSER:-}" ]; then log "inject deferred: supervisor composer not confirmed-empty (state=${FM_RENDERED_BUSY_RECOVERY_COMPOSER}: pending input, dead-shell prompt, or unreadable pane; subcause=composer=${FM_RENDERED_BUSY_RECOVERY_COMPOSER}; recovery-from=rendered-busy; native-state=$native_state; matched-row=$matched_row)" else log "inject deferred: supervisor pane busy (agent mid-turn; subcause=rendered-busy; native-state=$native_state; matched-row=$matched_row)" diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index c76e047417c..dc1cbfebffd 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -213,12 +213,15 @@ The idle status footer visibly carried `1 shell · esc to interrupt`, so the his A real 150-second foreground Bash turn then produced repeated `rendered-busy` daemon deferrals carrying `native-state=working` and exact changing spinner rows such as `✽ Enchanting… (11s · ↓ 128 tokens)`. After the turn was interrupted, bright human composer text remained pending and unchanged with a `composer=pending` deferral. The colocated unit suites in `tests/fm-daemon.test.sh`, `tests/fm-backend-herdr.test.sh`, and `tests/fm-composer-lib.test.sh` pin the idle shell footer, agent-count and update footers, dim and dark-truecolor ghosts, real active-tool plus spinner context, exact matched-row logging, and the alarmed byte-stable false-busy recovery. +The exact Incident-B ANSI row captured by the pre-PR-94 broad matcher was ` ⏵⏵ bypass permissions on · 1 shell · esc to interrupt · ← 1 agent · ↓ to manage`. +Incident C, observed on 2026-08-25, is treated as the same family by inference because it predates sub-cause logging and no contrary evidence is recorded here. +The separately observed prompt-submit-to-spinner delivery race is excluded from this PR and filed as follow-up `fm-afk-prespinner-race-f1`. ```sh -HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh \ +HERDR_LAB_HELPER="$(git rev-parse --show-toplevel)/bin/fm-herdr-lab.sh" \ FM_AFK_HERDR_CLAUDE_LIVE=1 \ tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh -# verdict: idle-post-afk agent_status=working composer=empty pane_is_busy_rc=1 broad_match_rc=0 scoped_match_rc=1 subcause=idle native-state=working matched-row=none +# verdict: idle-post-afk agent_status=working composer=empty pane_is_busy_rc=1 broad_match_rc=0 scoped_match_rc=1 subcause=idle native-state=working matched-row= ⏵⏵ bypass permissions on · 1 shell · esc to interrupt · ← 1 agent · ↓ to manage # ok - real Herdr 0.8.2 + Claude 2.1.251 (Claude Code): native working with rendered-idle empty composer submits once # verdict: active-foreground agent_status=working composer=empty pane_is_busy_rc=0 broad_match_rc=0 scoped_match_rc=0 subcause=rendered-busy native-state=working matched-row=· Moseying… (12s · ↓ 127 tokens) # verdict: pending-human-text agent_status=idle composer=pending pane_is_busy_rc=1 broad_match_rc=1 scoped_match_rc=1 subcause=idle native-state=idle matched-row=none diff --git a/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh b/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh index b45d08827e3..1208af2e6e7 100755 --- a/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh +++ b/tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh @@ -126,10 +126,18 @@ composer_state() { } rendered_claude_busy() { - local capture - capture=$(fm_backend_capture herdr "$TARGET" 40 2>/dev/null) || return 1 + local capture caps + if ! declare -F fm_backend_herdr_capture_ansi >/dev/null 2>&1; then + fm_backend_source herdr >/dev/null 2>&1 || return 1 + fi + if capture=$(fm_backend_herdr_capture_ansi "$TARGET" 40 2>/dev/null) && [ -n "$capture" ]; then + caps=$'styled=1\ncursor=0\nidentity=0\nrows=12' + else + caps=$'styled=0\ncursor=0\nidentity=0\nrows=12' + capture=$(fm_backend_capture herdr "$TARGET" 40 2>/dev/null) || return 1 + fi capture=$(printf '%s' "$capture" | grep -v '^[[:space:]]*$' | tail -12) - fm_claude_current_footer_busy <<< "$capture" + fm_claude_current_footer_busy "$caps" <<< "$capture" } claude_pane_is_busy() { @@ -216,15 +224,21 @@ screen_ansi() { } emit_verdict_evidence() { - local label=$1 busy_rc=1 broad_rc=1 scoped_rc=1 capture + local label=$1 busy_rc=1 broad_rc=1 scoped_rc=1 capture caps claude_pane_is_busy && busy_rc=0 - capture=$(fm_backend_capture herdr "$TARGET" 40 2>/dev/null | grep -v '^[[:space:]]*$' | tail -12) + if capture=$(fm_backend_herdr_capture_ansi "$TARGET" 40 2>/dev/null) && [ -n "$capture" ]; then + caps=$'styled=1\ncursor=0\nidentity=0\nrows=12' + else + caps=$'styled=0\ncursor=0\nidentity=0\nrows=12' + capture=$(fm_backend_capture herdr "$TARGET" 40 2>/dev/null) + fi + capture=$(printf '%s' "$capture" | grep -v '^[[:space:]]*$' | tail -12) fm_busy_lines_match claude <<< "$capture" && broad_rc=0 - fm_claude_current_footer_busy <<< "$capture" && scoped_rc=0 + fm_claude_current_footer_busy "$caps" <<< "$capture" && scoped_rc=0 printf 'verdict: %s agent_status=%s composer=%s pane_is_busy_rc=%s broad_match_rc=%s scoped_match_rc=%s subcause=%s native-state=%s matched-row=%s\n' \ "$label" "$(agent_status)" "$(composer_state)" "$busy_rc" "$broad_rc" "$scoped_rc" \ "${FM_PANE_BUSY_REASON:-idle}" "${FM_PANE_NATIVE_BUSY_STATE:-unknown}" \ - "${FM_PANE_BUSY_MATCHED_ROW:-none}" + "${FM_BUSY_MATCHED_ROW:-none}" printf 'ansi-rows-begin: %s\n' "$label" screen_ansi | tail -n 16 printf 'ansi-rows-end: %s\n' "$label" diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 1a759db31cc..da15ca8ce5e 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -676,7 +676,7 @@ test_pi_submit_observation_rejects_nonmatching_pending_literal test_claude_current_footer_requires_selected_composer_adjacency() { local foreign genuine rc foreign=$'────────────────────────\n❯\n────────────────────────\nClaude 4.1\n✲ Working… (4s)\n' - if printf '%s' "$foreign" | fm_claude_current_footer_busy; then + if printf '%s' "$foreign" | fm_claude_current_footer_busy "$CAPS_PLAIN"; then fail "a foreign Working row below an idle Claude composer must not read busy" else rc=$? @@ -685,7 +685,7 @@ test_claude_current_footer_requires_selected_composer_adjacency() { || fail "a foreign Working row outside the selected composer boundary must read unknown, got rc=$rc" genuine=$'────────────────────────\n❯\n────────────────────────\n✲ Pollinating… (16s · ↓ 1.1k tokens)\n' - printf '%s' "$genuine" | fm_claude_current_footer_busy \ + printf '%s' "$genuine" | fm_claude_current_footer_busy "$CAPS_PLAIN" \ || fail "a genuine Claude mid-turn footer immediately below its composer must read busy" pass "fm_claude_current_footer_busy: the footer belongs to the selected composer boundary" } @@ -701,13 +701,13 @@ test_claude_incident_b_footer_matrix_stays_idle() { "$base"$'\n ✔ Update installed · Restart to update' \ "$base"$'\n\033[2m Try “write tests”\033[0m' \ "$base"$'\n\033[38;2;72;72;72m Try “write tests”\033[39m'; do - if printf '%s' "$screen" | fm_claude_current_footer_busy; then + if printf '%s' "$screen" | fm_claude_current_footer_busy "$CAPS_STYLED"; then fail "incident-B nested busy text plus an idle Claude footer must not read busy" else rc=$? fi - [ "$rc" -ne 0 ] \ - || fail "incident-B footer fixture unexpectedly returned busy" + [ "$rc" -eq 1 ] \ + || fail "incident-B footer fixture must return the idle verdict rc=1, got rc=$rc" done printf '%s' "$base" | fm_busy_lines_match claude \ @@ -718,10 +718,10 @@ test_claude_incident_b_footer_matrix_stays_idle() { fi printf '%s' $'────────────────────────\n❯\n────────────────────────\nesc to interrupt' \ - | fm_claude_current_footer_busy \ + | fm_claude_current_footer_busy "$CAPS_PLAIN" \ || fail "a genuine current Claude esc-to-interrupt footer must remain busy" printf '%s' $'────────────────────────\n❯\n────────────────────────\n✲ Pollinating… (16s · ↓ 1.1k tokens)' \ - | fm_claude_current_footer_busy \ + | fm_claude_current_footer_busy "$CAPS_PLAIN" \ || fail "a genuine current Claude spinner footer must remain busy" pass "fm_claude_current_footer_busy: incident-B footer and ghost variants stay idle while genuine current turns stay busy" } @@ -731,7 +731,7 @@ test_claude_incident_b_footer_matrix_stays_idle test_claude_active_spinner_pairs_with_active_composer() { local active idle pending rc active=$'tool output:\n ⏺ Running… (43s · timeout 3m 20s)\n (ctrl+b to run in background)\n\n✶ Jitterbugging… (45s · ↓ 127 tokens)\n ⎿ Tip: Send messages to Claude while it works\n\n────────────────────────\n❯ \033[2mPress up to edit queued messages\033[0m\n────────────────────────\n ⏵⏵ bypass permissions on · 1 shell · esc to interrupt · ← 1 agent · ↓ to manage' - fm_claude_current_footer_busy <<< "$active" \ + fm_claude_current_footer_busy "$CAPS_STYLED_NOID" <<< "$active" \ || fail "a real Claude spinner paired with the active composer hint must read busy" [ "$FM_CLAUDE_BUSY_MATCHED_ROW" = '✶ Jitterbugging… (45s · ↓ 127 tokens)' ] \ || fail "the active Claude verdict did not expose its exact matched spinner row: ${FM_CLAUDE_BUSY_MATCHED_ROW:-unset}" @@ -739,7 +739,7 @@ test_claude_active_spinner_pairs_with_active_composer() { idle=${active/Press up to edit queued messages/} idle=${idle/Running… (43s · timeout 3m 20s)/Running in the background (↓ to manage)} idle=${idle/(ctrl+b to run in background)/} - if fm_claude_current_footer_busy <<< "$idle"; then + if fm_claude_current_footer_busy "$CAPS_STYLED_NOID" <<< "$idle"; then fail "the same stale spinner above a bare idle composer must not read busy" else rc=$? @@ -750,7 +750,7 @@ test_claude_active_spinner_pairs_with_active_composer() { || fail "the incident-B broad matcher must still reproduce on the idle shell footer" pending=${active/$'\033[2mPress up to edit queued messages\033[0m'/bright-human-draft} - if fm_claude_current_footer_busy <<< "$pending"; then + if fm_claude_current_footer_busy "$CAPS_STYLED_NOID" <<< "$pending"; then fail "stale active-tool rows must not outrank bright pending composer text" else rc=$? @@ -762,6 +762,21 @@ test_claude_active_spinner_pairs_with_active_composer() { test_claude_active_spinner_pairs_with_active_composer +test_claude_plain_capture_active_hint_uses_actual_capabilities() { + local active rc + active=$'tool output:\n ⏺ Running… (43s · timeout 3m 20s)\n (ctrl+b to run in background)\n\n✶ Jitterbugging… (45s · ↓ 127 tokens)\n ⎿ Tip: Send messages to Claude while it works\n\n────────────────────────\n❯ Press up to edit queued messages\n────────────────────────\n ⏵⏵ bypass permissions on · 1 shell · esc to interrupt · ← 1 agent · ↓ to manage' + if fm_claude_current_footer_busy "$CAPS_PLAIN" <<< "$active"; then + fail "a plain Herdr capture without styling proof must not read the dim active hint as busy" + else + rc=$? + fi + [ "$rc" -eq 2 ] \ + || fail "a plain active-context capture must remain unreadable without styling proof, got rc=$rc" + pass "fm_claude_current_footer_busy: a plain capture carries its real capability into active-context classification" +} + +test_claude_plain_capture_active_hint_uses_actual_capabilities + test_submit_retry_reports_send_failed_before_any_enter() { local out submit_key_always_fails() { return 1; } diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 004d9dbeafc..1234baa7434 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -2230,6 +2230,25 @@ test_inject_msg_detects_claude_harness_before_submit() { pass "inject_msg: detected Claude harness survives pane_is_busy into the submit boundary" } +test_pane_is_busy_herdr_claude_uses_ansi_capture_capability() { + ( + fm_backend_busy_state() { printf 'busy'; } + fm_backend_herdr_capture_ansi() { + printf '%b' 'tool output:\n ⏺ Running… (43s · timeout 3m 20s)\n (ctrl+b to run in background)\n\n✶ Jitterbugging… (45s · ↓ 127 tokens)\n ⎿ Tip: Send messages to Claude while it works\n\n────────────────────────\n❯ \033[2mPress up to edit queued messages\033[0m\n────────────────────────\n ⏵⏵ bypass permissions on · 1 shell · esc to interrupt · ← 1 agent · ↓ to manage' + } + fm_backend_capture() { fail "Herdr Claude busy guard fell back to a plain capture"; } + FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr \ + || fail "pane_is_busy should recognize the ANSI active-turn capture" + [ "$FM_PANE_BUSY_REASON" = rendered-busy ] \ + || fail "ANSI active-turn capture did not record rendered-busy: ${FM_PANE_BUSY_REASON:-unset}" + [ "$FM_PANE_BUSY_MATCHED_ROW" = '✶ Jitterbugging… (45s · ↓ 127 tokens)' ] \ + || fail "ANSI active-turn capture did not expose the exact spinner row: ${FM_PANE_BUSY_MATCHED_ROW:-unset}" + ) || fail "Herdr+Claude ANSI capture busy-guard subshell failed" + pass "pane_is_busy: Herdr+Claude uses ANSI capabilities for active-turn classification" +} + +test_pane_is_busy_herdr_claude_uses_ansi_capture_capability + test_pane_is_busy_herdr_claude_rendered_busy_state() { local dir dir=$(make_supercase primary-herdr-claude-rendered-busy) @@ -2455,6 +2474,39 @@ test_inject_msg_rendered_recovery_stays_fail_safe() { pass "inject_msg: post-alarm recovery never overrides a static live footer or a pending composer" } +test_inject_msg_rendered_recovery_rejects_unknown_native_state() { + local dir state sent + dir=$(make_supercase inject-rendered-recovery-native-unknown) + state="$dir/state" + sent="$dir/sent" + afk_enter "$state" + printf 'none\t-\tnative\n' > "$state/.afk-daemon-terminal" + printf '%s\n' 'alarm already fired' > "$state/.subsuper-inject-wedged" + ( + fm_backend_target_exists() { return 0; } + pane_is_busy() { + FM_PANE_BUSY_REASON=rendered-busy + FM_PANE_NATIVE_BUSY_STATE=unknown + FM_PANE_BUSY_MATCHED_ROW='• Working (4s • esc to interrupt)' + return 0 + } + fm_backend_composer_state() { fail "native-unknown recovery must not consult the composer"; } + fm_backend_send_text_submit() { printf '%s\n' "$1" >> "$sent"; printf 'empty'; } + FM_DAEMON_PRIMARY_HARNESS=claude + FM_RENDERED_BUSY_RECOVERY_POLLS=1 + LOG="$dir/daemon.log" + FM_SUPERVISOR_BACKEND=herdr + FM_SUPERVISOR_TARGET="default:w1:p2" + if inject_msg "hello" "$state"; then + fail "native-unknown recovery admitted an unproven native state" + fi + ) || fail "native-unknown rendered recovery subshell failed" + [ ! -s "$sent" ] || fail "native-unknown rendered recovery typed into the supervisor pane" + grep -F 'subcause=native-unknown' "$dir/daemon.log" >/dev/null \ + || fail "native-unknown recovery did not log its fail-safe subcause: $(cat "$dir/daemon.log")" + pass "inject_msg: rendered-busy recovery defers when native state is unknown" +} + test_inject_msg_herdr_claude_unreadable_capture_defers() { local dir state dir=$(make_supercase inject-herdr-claude-unreadable) @@ -2747,6 +2799,7 @@ test_inject_msg_logs_native_busy_subcause test_inject_msg_logs_rendered_busy_subcause test_inject_msg_recovers_stable_rendered_false_busy_after_alarm test_inject_msg_rendered_recovery_stays_fail_safe +test_inject_msg_rendered_recovery_rejects_unknown_native_state test_inject_msg_ignores_nested_claude_busy_text_above_idle_composer test_inject_msg_herdr_claude_unreadable_capture_defers test_primary_busy_guard_is_harness_scoped From 221cb2b193306e50ead32f77512e3f166a5e2524 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Mon, 31 Aug 2026 10:06:01 +0800 Subject: [PATCH 3/4] no-mistakes(document): Documented Claude away-mode recovery and verification facts --- .agents/skills/afk/SKILL.md | 6 ++++-- bin/fm-supervise-daemon.sh | 2 +- docs/configuration.md | 1 + docs/herdr-backend.md | 12 +++++++++--- docs/verification/runtime-backends.md | 2 +- docs/verification/supervision.md | 3 +++ 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 044b49656a5..c4dd5e5a0b2 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -93,9 +93,10 @@ injection, dispatched through `bin/fm-backend.sh` for the supervisor's own backend (tmux or herdr; see "Auto-discovered supervisor pane" below): - **Primary-pane busy guard** - `pane_is_busy` keeps the Herdr native-busy fast path except for a Herdr primary detected as Claude, where native `working` is diagnostic only because the tracked away daemon shell can keep it set after the foreground turn ends. - For that pair, the shared position- and shape-aware current-footer predicate is the rendered busy proof used before injection and around submit confirmation; rendered idle falls through to the affirmative `empty` composer guard, and unreadable or structurally ambiguous capture still defers. + For that pair, the shared position- and shape-aware current-context predicate is the rendered busy proof used before injection and around submit confirmation; styled capture capabilities keep dim and dark truecolor ghosts out of the typed-input verdict, rendered idle falls through to the affirmative `empty` composer guard, and unreadable or structurally ambiguous capture still defers. Submit confirmation for the same pair requires that predicate to transition from idle across the queued Enter or requires the composer to clear; native `working` alone never proves delivery. - Busy or composer deferrals name `native-busy`, `rendered-busy`, or `composer=` in the daemon log, while an unreadable busy-guard capture is named `unreadable`; Herdr's semantic `busy` diagnostic is recorded as native `working`. + Busy or composer deferrals name `native-busy`, `rendered-busy`, or `composer=` in the daemon log, while an unreadable busy-guard capture is named `unreadable`; every `rendered-busy` deferral includes the exact matched row, and Herdr's semantic `busy` diagnostic is recorded as native `working`. + The post-alarm Herdr+Claude recovery exception is the byte-stable elapsed-row gate described in `docs/herdr-backend.md`; it never overrides a pending or unknown composer verdict. The full contract is in `docs/herdr-backend.md` under “Away-mode supervisor support”. - **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. @@ -115,6 +116,7 @@ an ERROR in the daemon log, a durable `state/.subsuper-inject-wedged` marker (surface it on the "while you were out" 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. +After that alarm, only the documented Herdr+Claude byte-stable rendered-row recovery may recheck the composer, and it still requires exact `empty`. So a guard false-positive becomes a visible stall, never an unbounded silent no-op. ## Submit model diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 02f785d4995..8ff57744f84 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -158,7 +158,7 @@ FM_DAEMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_DAEMON_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" -# Shared tmux pane primitives for supervisor injection (busy/composer detection +# Shared pane primitives for supervisor injection (busy/composer detection # + verify-retry submit). Sourced at top level so BOTH the executed daemon and # the unit tests (which source this file for its pure functions) get the # corrected composer detection. Stale task rechecks use fm-backend.sh below. diff --git a/docs/configuration.md b/docs/configuration.md index 6a4253ee411..598817c1eb6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -800,6 +800,7 @@ FM_SUPERVISOR_TARGET= # optional supervisor pane target override; t FM_INJECT_SKIP=heartbeat # |-prefixes force-self-handled bypassing classification; empty disables FM_ESCALATE_BATCH_SECS=90 # buffer window for batched escalation digests; 0 = flush immediately FM_MAX_DEFER_SECS=300 # max buffered escalation age before retry plus wedge alarm; 0 disables +FM_RENDERED_BUSY_RECOVERY_POLLS=3 # byte-identical Claude rendered-busy rows required after a max-defer alarm before Herdr+Claude rechecks the composer; invalid or zero uses 3 FM_WEDGE_ALARM_CHANNEL= # override config/wedge-alarm with one active-alert directive for the wedge alarm; off|auto|osascript|herdr|command:; absent = auto (macOS -> an OS notification) FM_WEDGE_ALARM_EXEC= # notifier seam: route every channel (osascript, herdr, command:) through this command as ` `; "discard" fires nothing; unset in production; the daemon defaults it to "discard" when sourced so no test posts a real notification (docs/wedge-alarm.md) FM_WEDGE_ALARM_TIMEOUT_SECS=10 # maximum seconds for each osascript, herdr, override, or command: notifier before its watchdog terminates it and continues to the next channel; invalid or zero values use 10 diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 58c2ad28134..b6c6747e232 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -224,11 +224,17 @@ Long Pi literals use the same bounded structural observation as short literals, A valid non-empty Pi pair must also contain bounded leading and trailing anchors from the current literal; a mismatch reports `text-not-typed` before Enter instead of treating another retained draft as the current message. A Pi message still visibly present after the final wait reports `not-submitted` when it is a proven pending composer state; an otherwise unproven send reports `pending-unproven` and exits 3, as does a cleared or unreadable surface without either positive signal. For a known Claude target, Herdr's native `working` signal is not that generating proof because a tracked background shell can keep it set after the foreground turn ends. -Claude's away-mode busy guard and queued-Enter confirmation use the same position- and shape-aware current-footer predicate. -It accepts an active-turn signature only when the final nonblank footer row is immediately below the selected composer boundary, including a structural closing edge the shared classifier proves for that screen shape. +Claude's away-mode busy guard and queued-Enter confirmation use the same position- and shape-aware current-context predicate owned by `bin/fm-composer-lib.sh`. +It accepts an active-turn signature only when the selected composer boundary and its adjacent activity rows form a verified Claude context, including the structural closing edge the shared classifier proves for that screen shape. +A status footer by itself, nested or quoted busy text outside that context, or an elapsed token without that context is not busy. +A styled capture's capability descriptor is passed into the predicate, so dim suggestions and dark truecolor ghosts cannot become bright typed text, while an unstyled fallback remains conservative. A footer-like row outside that boundary is structurally ambiguous and returns `unknown`, including a foreign `Working` row below an idle Claude composer. The Enter is confirmed only when that predicate changes from idle immediately before the Enter to busy after it, or when the composer clears; native `working` alone and a pre-existing rendered-busy footer never prove that Enter. Rendered idle with pending text remains unconfirmed and preserves the escalation for retry. +After a max-defer alarm, only the Herdr+Claude path may reconsider a `rendered-busy` result. +The exact matched row must include an elapsed duration and remain byte-identical for `FM_RENDERED_BUSY_RECOVERY_POLLS` polls, which defaults to 3, while native state is `idle` or `done`, or is `working` with the tracked daemon-terminal record. +Only then does the daemon re-read the composer and proceed on an exact `empty` verdict; a changed or durationless row, unknown native state, non-background `working` state, `pending` composer, or `unknown` composer remains deferred. +The recovery gate admits one submit attempt and resets its stability streak before that attempt; normal verified-submit confirmation remains the only path that clears the escalation buffer. For another non-Claude, non-Pi target with an already active or unreadable native baseline, the adapter falls back to conservative composer clearance, with a pre-Enter rendered-footer transition when that baseline is unavailable. A known Claude target captures its rendered baseline before each Enter, so a pre-existing active-turn footer cannot serve as submit confirmation. A fully unreadable target stops retrying and reports unknown. @@ -309,7 +315,7 @@ The pane-independent max-defer alert is configured in [`wedge-alarm.md`](wedge-a For a Herdr primary whose detected harness is Claude, native `agent_status=working` is diagnostic only during away-mode injection because Claude's tracked background daemon shell can keep that value working after the foreground turn ends. `pane_is_busy` therefore uses that shared current-footer predicate; footer-like text outside the selected composer boundary is inert, including nested worker output that can quote another harness's busy footer. When the rendered pane is idle, injection falls through to the affirmative `empty` composer guard, while an unreadable capture or any non-`empty` composer verdict still defers. -Each busy or composer deferral records the sub-cause as `native-busy`, `rendered-busy`, or `composer=`; an unreadable busy-guard capture is logged as `unreadable` and also defers. +Each busy or composer deferral records the sub-cause as `native-busy`, `rendered-busy`, or `composer=`; a `rendered-busy` record includes the exact matched row, and an unreadable busy-guard capture is logged as `unreadable` and also defers. The Herdr `busy` adapter result is logged as its native `working` label; for Claude this preserves diagnostic evidence without making it a busy verdict. Every other harness/backend combination retains its native-busy fast path. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index e8280910bd0..ec4264f41f9 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -641,7 +641,7 @@ FM_AFK_PI_HERDR_E2E=1 HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ Observed guarantees: pending composer input refused injection and raised one alert; idle Pi accepted one marked escalation; the return gate refused ordinary work while a live blocker remained; resolving the blocker allowed the return flow. The dedicated Herdr daemon workspace topology is covered by `tests/fm-afk-launch.test.sh` and preserves the captain tab's pane count. The 2026-08-31 Herdr 0.8.2 plus Claude Code 2.1.251 away-mode result is recorded in [supervision verification](supervision.md#herdrclaude-away-mode-busy-guard-2026-08-31). -The live guard is refreshed with `HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh FM_AFK_HERDR_CLAUDE_LIVE=1 tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh`. +The live guard is refreshed from the repository root with `HERDR_LAB_HELPER="$(git rev-parse --show-toplevel)/bin/fm-herdr-lab.sh" FM_AFK_HERDR_CLAUDE_LIVE=1 tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh`. It proves the broad idle-footer false positive, scoped idle delivery, repeated real foreground-turn deferral with exact spinner rows, and pending human-text preservation in one named non-default lab session. ## Zellij diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index dc1cbfebffd..888dfe4a9ff 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -213,7 +213,10 @@ The idle status footer visibly carried `1 shell · esc to interrupt`, so the his A real 150-second foreground Bash turn then produced repeated `rendered-busy` daemon deferrals carrying `native-state=working` and exact changing spinner rows such as `✽ Enchanting… (11s · ↓ 128 tokens)`. After the turn was interrupted, bright human composer text remained pending and unchanged with a `composer=pending` deferral. The colocated unit suites in `tests/fm-daemon.test.sh`, `tests/fm-backend-herdr.test.sh`, and `tests/fm-composer-lib.test.sh` pin the idle shell footer, agent-count and update footers, dim and dark-truecolor ghosts, real active-tool plus spinner context, exact matched-row logging, and the alarmed byte-stable false-busy recovery. +The reconstructed failure separates trigger, masking condition, and symptom: `/afk` left Claude's tracked background Bash visible to Herdr as native `working`; the pre-PR-94 unanchored last-12-row matcher treated the exact status-footer token as rendered busy before the composer guard; and the daemon retained the digest until the max-defer alarm. The exact Incident-B ANSI row captured by the pre-PR-94 broad matcher was ` ⏵⏵ bypass permissions on · 1 shell · esc to interrupt · ← 1 agent · ↓ to manage`. +Replacing only `esc to interrupt` in that row with `turn finished` flipped the broad matcher to idle while the scoped matcher stayed idle, proving the smallest footer-token counterfactual. +The recovery matrix admits three identical `• Working (4s • esc to interrupt)` rows only with the tracked daemon-terminal condition and an `empty` composer; a durationless row, a pending composer, and unknown native state remain deferred. Incident C, observed on 2026-08-25, is treated as the same family by inference because it predates sub-cause logging and no contrary evidence is recorded here. The separately observed prompt-submit-to-spinner delivery race is excluded from this PR and filed as follow-up `fm-afk-prespinner-race-f1`. From d4e439d3eeef4b583390dd0712e4f4257045f56a Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Mon, 31 Aug 2026 10:10:20 +0800 Subject: [PATCH 4/4] no-mistakes(lint): Quote native-unknown recovery subcause; fm-lint passes --- bin/fm-supervise-daemon.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 8ff57744f84..5b6f83ba602 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -691,7 +691,7 @@ rendered_busy_recovery_ready() { # < idle|done) ;; *) - FM_RENDERED_BUSY_RECOVERY_SUBCAUSE=native-unknown + FM_RENDERED_BUSY_RECOVERY_SUBCAUSE='native-unknown' FM_RENDERED_BUSY_LAST_ROW= FM_RENDERED_BUSY_STREAK=0 return 1