diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 3170deb89ff..673977055c8 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -315,11 +315,13 @@ fm_composer_strip_ghost() { # outside its composer and the composer verdict is therefore always `unknown`. 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_CURRENT_FOOTER_REGEX='^[[:space:]]*(esc to interrupt|thinking\.\.\.[[:space:]]+esc to interrupt|([^[:space:]]+[[:space:]]+)?[^[:space:]]+…([[:space:]]+\([0-9]+[smh]([[: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_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX='^[[:space:]]*⏵⏵[[:space:]]+bypass[[:space:]]+permissions[[:space:]]+on' -FM_DELIVERY_CLAUDE_STATUS_PRIMARY_REGEX='^[[:space:]]*⏵⏵[[:space:]]+bypass[[:space:]]+permissions[[:space:]]+on([[:space:]]+[(]shift[+]tab[[:space:]]+to[[:space:]]+cycle[)]|[[:space:]]+·[[:space:]]+(←[[:space:]]+[[:digit:]]+[[:space:]]+agent([[:space:]]+·[[:space:]]+↓[[:space:]]+to[[:space:]]+manage)?|[[:digit:]]+[[:space:]]+shell([[:space:]]+·[[:space:]]+esc[[:space:]]+to[[:space:]]+interrupt)?[[:space:]]+·[[:space:]]+←[[:space:]]+[[:digit:]]+[[:space:]]+(agent|a…)([[:space:]]+·[[:space:]]+↓[[:space:]]+to[[:space:]]+manage)?))[[:space:]]*$' +FM_DELIVERY_CLAUDE_PERMISSION_MODE_REGEX='(bypass[[:space:]]+permissions|auto[[:space:]]+mode|accept[[:space:]]+edits|plan[[:space:]]+mode)' +FM_DELIVERY_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX="^[[:space:]]*⏵⏵[[:space:]]+${FM_DELIVERY_CLAUDE_PERMISSION_MODE_REGEX}[[:space:]]+on" +FM_DELIVERY_CLAUDE_STATUS_PRIMARY_REGEX="${FM_DELIVERY_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX}([[:space:]]+[(]shift[+]tab[[:space:]]+to[[:space:]]+cycle[)])?([[:space:]]+·[[:space:]]+(←[[:space:]]+[[:digit:]]+[[:space:]]+(agents?|a…)|[[:digit:]]+[[:space:]]+shells?|↓[[:space:]]+to[[:space:]]+manage|esc[[:space:]]+to[[:space:]]+interrupt))*([[:space:]]+(/rc|●[[:space:]]+(low|medium|high)[[:space:]]+·[[:space:]]+/effort))?[[:space:]]*$" +FM_DELIVERY_CLAUDE_STATUS_IDLE_REGEX='^[[:space:]]*[?][[:space:]]+for[[:space:]]+shortcuts[[:space:]]*$' FM_DELIVERY_CLAUDE_STATUS_CONTINUATION_REGEX='^[[:space:]]*(/rc|●[[:space:]]+(low|medium|high)[[:space:]]+·[[:space:]]+/effort)[[:space:]]*$' FM_DELIVERY_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt' FM_DELIVERY_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt' @@ -378,6 +380,7 @@ fm_claude_current_footer_busy() { footer_shape=$(printf '%s\n' "$plain" | awk \ -v primary="$FM_DELIVERY_CLAUDE_STATUS_PRIMARY_REGEX" \ -v prefix="$FM_DELIVERY_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX" \ + -v idle="$FM_DELIVERY_CLAUDE_STATUS_IDLE_REGEX" \ -v continuation="$FM_DELIVERY_CLAUDE_STATUS_CONTINUATION_REGEX" ' { rows[NR]=$0 } function previous_nonblank(from, row) { @@ -392,7 +395,7 @@ fm_claude_current_footer_busy() { count++ row=previous_nonblank(row - 1) } - if (row > 0 && rows[row] ~ primary) print "valid:" row + if (row > 0 && (rows[row] ~ primary || rows[row] ~ idle)) print "valid:" row else if (count > 0 || (row > 0 && rows[row] ~ prefix)) print "invalid" } ') @@ -711,9 +714,22 @@ fm_composer_classify_content() { # [idle_re] [idle_case] [ # exact positive proof they require (`empty`), so unrecognized future verdicts # fail safe by default. -# _fm_composer_pi_separator_row: a solid pi separator - nothing but `─`, at +# _fm_composer_titled_rule_row: Claude's titled-rule composer boundary starts +# with a long `─` run and includes non-rule title text, so it is distinct from +# Pi's solid separator and still proves the adjacent bare composer shape. +# _fm_composer_pi_separator_row: a solid Pi separator - nothing but `─`, at # least 8 columns wide. The width floor is a literal substring test so it is # byte-exact in every locale. +_fm_composer_titled_rule_row() { # + local row=$1 + fm_composer_normalize_trim_var row + case "$row" in + '────────'*'─') ;; + *) return 1 ;; + esac + [ -n "${row//─/}" ] +} + _fm_composer_pi_separator_row() { # local row=$1 [ -n "$row" ] || return 1 @@ -1274,8 +1290,20 @@ _fm_composer_select_cursorless() { fi if [ "$FM_COMPOSER_SCAN_PI_PAIR_FOUND" = 0 ] \ && [ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -gt "$generic" ]; then - FM_COMPOSER_SELECTED_KIND= - return 1 + if [ "$FM_COMPOSER_SELECTED_KIND" = bare ] \ + && [ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -eq $((FM_COMPOSER_SCAN_BARE_ROW + 1)) ] \ + && [ "$FM_COMPOSER_SCAN_BARE_ROW" -gt 0 ]; then + raw=$(_fm_composer_screen_row "$((FM_COMPOSER_SCAN_BARE_ROW - 1))" "$plain") + trimmed=$raw + fm_composer_normalize_trim_var trimmed + if ! _fm_composer_titled_rule_row "$trimmed"; then + FM_COMPOSER_SELECTED_KIND= + return 1 + fi + else + FM_COMPOSER_SELECTED_KIND= + return 1 + fi fi if [ "$FM_COMPOSER_SCAN_SHELL_ROW" -gt "$generic" ]; then FM_COMPOSER_SELECTED_KIND= diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 4ab1f4728b9..2702df41070 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -32,9 +32,12 @@ # primary checkout - the main home or a genuinely marked secondmate home - and # stay a silent, fast no-op inside child task worktrees. # -# Away mode (state/.afk): the away-mode daemon owns supervision and runs the -# watcher one-shot, restarting it after every wake, so the watch lock is -# regularly unheld at a turn boundary with nothing wrong. A live +# Away mode (state/.afk): this synchronous turn-boundary guard checks the +# away-daemon owner before it trusts the ownership transfer. If the daemon's +# identity-backed lock is dead or mismatched, it asks bin/fm-afk-launch.sh - the +# terminal-lifecycle owner - to relaunch through its tracked native path. The +# away daemon runs the watcher one-shot, restarting it after every wake, so the +# watch lock is regularly unheld at a turn boundary with nothing wrong. A live # identity-matched daemon holding this home, plus the unchanged fresh-beacon # test, is what proves supervision there - see fm_afk_daemon_owns_supervision in # bin/fm-wake-lib.sh. The strict watcher predicate is unchanged everywhere else. @@ -181,9 +184,38 @@ allow_supervised_stop() { exit 2 } -if fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then - allow_supervised_stop -fi +fm_afk_relaunch_heartbeat_mtime() { + local beat=$1 + if [ "$(uname)" = Darwin ]; then + stat -f '%.9Fm' "$beat" 2>/dev/null + else + stat -c '%y' "$beat" 2>/dev/null | awk '{print $1 "T" $2}' + fi +} + +fm_afk_relaunch_heartbeat_ready() { + local previous=$1 current + fm_afk_daemon_owns_supervision "$STATE" || return 1 + current=$(fm_afk_relaunch_heartbeat_mtime "$STATE/.last-watcher-beat" || true) + [ -n "$current" ] || return 1 + case "$previous" in + absent) ;; + ''|unreadable) return 1 ;; + *) [ "$current" \> "$previous" ] || return 1 ;; + esac + fm_supervision_status "$STATE" "$GRACE" + [ "$FM_SUP_WATCHER_FRESH" = true ] +} + +fm_afk_wait_for_relaunch_heartbeat() { + local previous=$1 attempt=0 + while [ "$attempt" -lt 30 ]; do + attempt=$((attempt + 1)) + fm_afk_relaunch_heartbeat_ready "$previous" && return 0 + sleep 0.1 + done + return 1 +} # Away mode transfers supervision ownership from the watcher to the away-mode # daemon, which runs the watcher one-shot and starts its replacement after every @@ -194,7 +226,26 @@ fi # The beacon half of the predicate is deliberately unchanged: a daemon that # stops restarting its watcher still blocks once the beacon passes grace, and # a home with no daemon and no watcher blocks exactly as before. -if [ "$FM_SUP_WATCHER_FRESH" = true ] && fm_afk_daemon_owns_supervision "$STATE"; then +if [ -e "$STATE/.afk" ]; then + AFK_RELAUNCH_READY=0 + if fm_afk_daemon_owns_supervision "$STATE"; then + AFK_RELAUNCH_READY=1 + else + AFK_RELAUNCH_BEAT_PREVIOUS=absent + if [ -e "$STATE/.last-watcher-beat" ]; then + AFK_RELAUNCH_BEAT_PREVIOUS=$(fm_afk_relaunch_heartbeat_mtime "$STATE/.last-watcher-beat" || true) + [ -n "$AFK_RELAUNCH_BEAT_PREVIOUS" ] || AFK_RELAUNCH_BEAT_PREVIOUS=unreadable + fi + if "$SCRIPT_DIR/fm-afk-launch.sh" start >/dev/null 2>&1; then + fm_afk_wait_for_relaunch_heartbeat "$AFK_RELAUNCH_BEAT_PREVIOUS" && AFK_RELAUNCH_READY=1 + fi + fi + if [ "$AFK_RELAUNCH_READY" -eq 1 ] \ + && [ "$FM_SUP_WATCHER_FRESH" = true ] \ + && fm_afk_daemon_owns_supervision "$STATE"; then + allow_supervised_stop + fi +elif fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then allow_supervised_stop fi diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index aef651503e9..55cfdd163d4 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -516,6 +516,14 @@ "path": "tests/fixtures/claude-herdr-2.1.258/idle-background-narrow.ansi.txt", "audience": "maintainer-verification" }, + { + "path": "tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-busy.txt", + "audience": "maintainer-verification" + }, + { + "path": "tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-idle.txt", + "audience": "maintainer-verification" + }, { "path": "tests/fixtures/pi-0.85.0-calm-composer/herdr-calm-off-idle.ansi.txt", "audience": "maintainer-verification" diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 229495aa891..89927eb6c72 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -775,8 +775,34 @@ Observed guarantees: pending composer input refused injection and raised one ale The dedicated Herdr daemon workspace topology is covered by `tests/fm-afk-launch.test.sh` and preserves the captain tab's pane count. The daemon's delivered-once transcript witness is pinned portably by `tests/fm-daemon.test.sh`; refresh the real Herdr plus Pi path against an existing disposable pane, without lifecycle operations, with `FM_AFK_DELIVERY_WITNESS_LIVE=1 FM_AFK_DELIVERY_WITNESS_LIVE_TARGET=':' FM_AFK_DELIVERY_WITNESS_LIVE_HOME='' tests/fm-afk-delivery-witness-live-e2e.test.sh`. The current 2026-09-03 Herdr 0.8.2 plus Claude Code 2.1.259 wrapped-footer away-mode result is recorded in [supervision verification](supervision.md#herdrclaude-wrapped-away-mode-footer-2026-09-03). -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 wrapped idle-footer parse, exactly-once delivery, repeated real foreground-turn deferral with exact spinner rows, and pending human-text preservation in one named non-default lab session. +The newer permission-footer family was verified on 2026-09-08 with Herdr 0.8.2 and Claude Code 2.1.263 in both auto and bypass-permissions modes. +The live guard now waits for three one-second byte-identical full-screen and footer/composer captures after the `/afk` foreground turn settles, because the prior one-sample readiness check could mistake a transient redraw for idle. +Its first-delivery bound is the three-second stable-idle window plus one one-second housekeeping tick plus two seconds of slack, measured on a monotonic clock, for a total of 6,000 ms. + +```sh +HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh \ + FM_AFK_HERDR_CLAUDE_LIVE=1 \ + FM_AFK_HERDR_CLAUDE_PERMISSION_MODE=auto \ + bin/fm-test-run.sh tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh +HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh \ + FM_AFK_HERDR_CLAUDE_LIVE=1 \ + FM_AFK_HERDR_CLAUDE_PERMISSION_MODE=bypassPermissions \ + bin/fm-test-run.sh tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh +``` + +Observed output: + +```text +ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (auto mode): native idle with rendered-idle empty composer submits once +ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (auto mode): rendered-busy and pending-composer deferrals preserve human text +evidence: permission-mode=auto native=idle rendered=idle composer=empty stable-footer-composer=3 delivery-ms=4059 delivery-bound-ms=6000 delivered_once=1 rendered-busy=1 native-state=working=1 composer=pending=1 +ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (bypass permissions): native idle with rendered-idle empty composer submits once +ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (bypass permissions): rendered-busy and pending-composer deferrals preserve human text +evidence: permission-mode=bypassPermissions native=idle rendered=idle composer=empty stable-footer-composer=3 delivery-ms=3784 delivery-bound-ms=6000 delivered_once=1 rendered-busy=1 native-state=working=1 composer=pending=1 +``` + +Both named non-default lab sessions tore down with the default-session tripwire intact. +The test-contract correction removed the stale foreground-settlement and two-second delivery assumptions; no production guard was weakened. ## Zellij diff --git a/tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-busy.txt b/tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-busy.txt new file mode 100644 index 00000000000..606ce2a945d --- /dev/null +++ b/tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-busy.txt @@ -0,0 +1,5 @@ +✳ Schlepping… (1m 19s · ↓ 3.5k tokens) +──────────────── Omawild homepage and closing tasks ─ +❯ +──────────────────────── + ⏵⏵ auto mode on (shift+tab to cycle) · ← 1 agent · esc to interrupt /rc diff --git a/tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-idle.txt b/tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-idle.txt new file mode 100644 index 00000000000..dc2a44714bd --- /dev/null +++ b/tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-idle.txt @@ -0,0 +1,10 @@ + Ran 2 shell commands + +⏺ Captain, that was the away-mode worker's first progress note: branch created, now reading the composer and daemon lifecycle owners. It's working normally. Still waiting on your word for the two crash follow-ups. + +✻ Baked for 23s · done 7:22 AM + +──────────────── Omawild homepage and closing tasks ─ +❯ +──────────────────────── + ⏵⏵ auto mode on (shift+tab to cycle) · ← 1 agent /rc 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 33f904907fa..ee5ab84918b 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 @@ -2,8 +2,8 @@ # Live Herdr+Claude regression for the away-mode native-background busy guard. # # This opt-in test uses the real Claude native `/afk` path, so the daemon's -# tracked background Bash remains visible to Herdr while Claude's foreground -# composer returns idle. +# tracked background Bash remains alive while Claude's foreground agent returns +# to an idle native state and empty composer. # It fails naming the Claude and Herdr versions instead of silently replacing # the real harness with a fixture. # Every Herdr operation, including adapter calls, is routed through the named @@ -13,6 +13,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" AFK_LAUNCH="$ROOT/bin/fm-afk-launch.sh" HERDR_LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} +CLAUDE_PERMISSION_MODE=${FM_AFK_HERDR_CLAUDE_PERMISSION_MODE:-auto} +STABLE_IDLE_CAPTURES=3 +STABLE_IDLE_INTERVAL_SECS=1 +HOUSEKEEPING_TICK_SECS=1 +DELIVERY_SLACK_SECS=2 +DELIVERY_BOUND_SECS=$((STABLE_IDLE_CAPTURES * STABLE_IDLE_INTERVAL_SECS + HOUSEKEEPING_TICK_SECS + DELIVERY_SLACK_SECS)) fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } pass() { printf 'ok - %s\n' "$1"; } @@ -27,9 +33,14 @@ for tool in herdr jq claude; do done [ -x "$HERDR_LAB_HELPER" ] \ || fail "FM_AFK_HERDR_CLAUDE_LIVE=1 but the Herdr lab helper is not executable at $HERDR_LAB_HELPER" +case "$CLAUDE_PERMISSION_MODE" in + auto) CLAUDE_FOOTER_MODE='auto mode' ;; + bypassPermissions) CLAUDE_FOOTER_MODE='bypass permissions' ;; + *) fail "unsupported FM_AFK_HERDR_CLAUDE_PERMISSION_MODE '$CLAUDE_PERMISSION_MODE' (expected auto or bypassPermissions)" ;; +esac ORIGINAL_PATH=$PATH -HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-afk-claude-herdr-unreadable-wedge-f1) \ +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-afk-herdr-claude-live-guard-2-1-263-f1) \ || 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" @@ -109,9 +120,9 @@ CLAUDE_TEST_SYSTEM_PROMPT="This is a live away-mode guard test. For an injected printf '%s\n' 'Reply with the single word ready and stop.' > "$PROMPT_FILE" # shellcheck disable=SC2016 # The generated launcher expands $(cat ...) when it runs. printf -v CLAUDE_LAUNCHER_CONTENT \ - '#!/usr/bin/env bash\nset -euo pipefail\ncd %q\nexport PATH=%q\nexport FM_HOME=%q\nexport FM_STATE_OVERRIDE=%q\nexport FM_ROOT_OVERRIDE=%q\nexport HERDR_SESSION=%q\nexport FM_ESCALATE_BATCH_SECS=0\nexport FM_HOUSEKEEPING_TICK=1\nexport FM_POLL=1\nexport FM_SIGNAL_GRACE=1\nexport FM_HEARTBEAT=999999\nexport FM_CHECK_INTERVAL=999999\nexport FM_STALE_ESCALATE_SECS=999999\nexport FM_INJECT_CONFIRM_SLEEP=0.5\nexport FM_INJECT_CONFIRM_RETRIES=4\nexport CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false\nstty cols 66 rows 39\nexec %q --dangerously-skip-permissions --append-system-prompt %q "$(cat %q)"\n' \ + '#!/usr/bin/env bash\nset -euo pipefail\ncd %q\nexport PATH=%q\nexport FM_HOME=%q\nexport FM_STATE_OVERRIDE=%q\nexport FM_ROOT_OVERRIDE=%q\nexport HERDR_SESSION=%q\nexport FM_ESCALATE_BATCH_SECS=0\nexport FM_HOUSEKEEPING_TICK=1\nexport FM_POLL=1\nexport FM_SIGNAL_GRACE=1\nexport FM_HEARTBEAT=999999\nexport FM_CHECK_INTERVAL=999999\nexport FM_STALE_ESCALATE_SECS=999999\nexport FM_INJECT_CONFIRM_SLEEP=0.5\nexport FM_INJECT_CONFIRM_RETRIES=4\nexport CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false\nexec %q --permission-mode %q --append-system-prompt %q "$(cat %q)"\n' \ "$ROOT" "$FAKEBIN:$ORIGINAL_PATH" "$FM_HOME" "$STATE_DIR" "$ROOT" \ - "$HERDR_LAB_SESSION" "$CLAUDE_BIN" "$CLAUDE_TEST_SYSTEM_PROMPT" "$PROMPT_FILE" + "$HERDR_LAB_SESSION" "$CLAUDE_BIN" "$CLAUDE_PERMISSION_MODE" "$CLAUDE_TEST_SYSTEM_PROMPT" "$PROMPT_FILE" printf '%s' "$CLAUDE_LAUNCHER_CONTENT" > "$CLAUDE_LAUNCHER" chmod +x "$CLAUDE_LAUNCHER" lab pane run "$PANE" "$CLAUDE_LAUNCHER" >/dev/null \ @@ -163,7 +174,7 @@ wait_for_initial_idle() { || return 1 trust_accepted=1 stable=0 - sleep 1 + sleep "$STABLE_IDLE_INTERVAL_SECS" continue fi fi @@ -197,19 +208,44 @@ wait_for_afk_daemon() { return 1 } -wait_for_idle_native_working() { - local status composer busy - for _ in $(seq 1 60); do +wait_for_stable_idle_daemon() { + local status composer busy capture caps rendered last_capture='' last_rendered='' stable=0 + for _ in $(seq 1 120); do status=$(agent_status) - composer=$(composer_state) + 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) || { + stable=0 + last_capture='' + last_rendered='' + sleep "$STABLE_IDLE_INTERVAL_SECS" + continue + } + fi + composer=$(fm_composer_classify_screen "$caps" "$capture") busy=1 claude_pane_is_busy || busy=0 - if [ "$status" = working ] && [ "$composer" = empty ] && [ "$busy" -eq 0 ]; then - return 0 + rendered=$(printf '%s' "$capture" | grep -v '^[[:space:]]*$' | tail -5) + if { [ "$status" = idle ] || [ "$status" = 'done' ]; } \ + && [ "$composer" = empty ] && [ "$busy" -eq 0 ] \ + && [ "${FM_PANE_BUSY_REASON:-idle}" != unreadable ]; then + if [ -n "$last_capture" ] && [ "$capture" = "$last_capture" ] \ + && [ "$rendered" = "$last_rendered" ]; then + stable=$((stable + 1)) + else + stable=1 + fi + [ "$stable" -ge "$STABLE_IDLE_CAPTURES" ] && return 0 + else + stable=0 fi - sleep 1 + last_capture=$capture + last_rendered=$rendered + sleep "$STABLE_IDLE_INTERVAL_SECS" done - echo "away-idle diagnostics: status=$status composer=$composer rendered_busy=$busy" >&2 + echo "away-idle diagnostics: status=$status composer=$composer rendered_busy=$busy stable=$stable" >&2 echo "Claude pane:" >&2 screen_text | tail -n 80 >&2 return 1 @@ -250,6 +286,19 @@ token_count() { printf '%s\n' "$screen" | grep -F -c "$token" || true } +wait_for_delivery_appearance() { + local token=$1 + for _ in $(seq 1 "$((DELIVERY_BOUND_SECS * 4))"); do + [ "$(token_count "$token")" -eq 1 ] && return 0 + sleep 0.25 + done + return 1 +} + +monotonic_ms() { + python3 -c 'import time; print(time.monotonic_ns() // 1000000)' +} + wait_for_single_delivery() { local token=$1 ack=$2 count ack_count composer screen native_status for _ in $(seq 1 60); do @@ -259,7 +308,8 @@ wait_for_single_delivery() { composer=$(composer_state) native_status=$(agent_status) if [ "$count" -eq 1 ] && [ "$ack_count" -eq 1 ] \ - && [ "$composer" = empty ] && [ "$native_status" = working ] \ + && [ "$composer" = empty ] \ + && { [ "$native_status" = idle ] || [ "$native_status" = 'done' ]; } \ && ! delivery_has_undelivered "$STATE_DIR"; then return 0 fi @@ -268,33 +318,6 @@ wait_for_single_delivery() { return 1 } -wait_for_wrapped_idle_background_footer() { - local screen - for _ in $(seq 1 30); do - screen=$(screen_text) - if printf '%s\n' "$screen" | awk ' - { rows[NR] = $0 } - function previous_nonblank(from, row) { - for (row = from; row >= 1; row--) - if (rows[row] !~ /^[[:space:]]*$/) return row - return 0 - } - END { - continuation = previous_nonblank(NR) - primary = previous_nonblank(continuation - 1) - if (continuation > 0 && primary > 0 \ - && rows[continuation] ~ /^[[:space:]]*\/rc[[:space:]]*$/ \ - && rows[primary] ~ /^[[:space:]]*⏵⏵ bypass permissions on[[:space:]]+·[[:space:]]+1 shell[[:space:]]+·[[:space:]]+←[[:space:]]+1 agent[[:space:]]+·[[:space:]]+↓[[:space:]]+to[[:space:]]+manage[[:space:]]*$/) exit 0 - exit 1 - } - '; then - return 0 - fi - sleep 0.2 - done - return 1 -} - wait_for_rendered_busy() { local token=$1 screen status composer for _ in $(seq 1 30); do @@ -411,23 +434,27 @@ if ! wait_for_afk_daemon; then screen_text | tail -n 80 >&2 fail "the real Claude /afk path did not leave a live native daemon record" fi -wait_for_idle_native_working \ - || fail "Herdr did not report working with an idle Claude composer after the foreground /afk turn" -wait_for_wrapped_idle_background_footer \ - || fail "Claude Code ($CLAUDE_VERSION) did not render the captured narrow two-row background footer" +wait_for_stable_idle_daemon \ + || fail "Herdr did not report a stable idle Claude composer after the foreground /afk turn" emit_verdict_evidence idle-post-afk ESCALATION_ONE="FM_AFK_CLAUDE_GUARD_ONE_$$" -DELIVERY_STARTED=$(date +%s) +DELIVERY_STARTED_MS=$(monotonic_ms) printf 'done: %s https://example.test/afk-one\n' "$ESCALATION_ONE" > "$STATE_DIR/crew-one.status" +wait_for_delivery_appearance "$ESCALATION_ONE" \ + || { + sed -n '1,$p' "$STATE_DIR/.supervise-daemon.log" >&2 2>/dev/null || true + fail "the first escalation did not appear within the bounded housekeeping window" + } +DELIVERY_ELAPSED_MS=$(( $(monotonic_ms) - DELIVERY_STARTED_MS )) +DELIVERY_BOUND_MS=$((DELIVERY_BOUND_SECS * 1000)) +[ "$DELIVERY_ELAPSED_MS" -le "$DELIVERY_BOUND_MS" ] \ + || fail "the first escalation took ${DELIVERY_ELAPSED_MS}ms; bound is ${DELIVERY_BOUND_MS}ms (${STABLE_IDLE_CAPTURES} stable captures x ${STABLE_IDLE_INTERVAL_SECS}s + ${HOUSEKEEPING_TICK_SECS}s housekeeping + ${DELIVERY_SLACK_SECS}s slack)" wait_for_single_delivery "$ESCALATION_ONE" "$AWAY_ACK_TOKEN" \ || { sed -n '1,$p' "$STATE_DIR/.supervise-daemon.log" >&2 2>/dev/null || true fail "the first escalation was not confirmed exactly once while the native background job remained running" } -DELIVERY_ELAPSED=$(( $(date +%s) - DELIVERY_STARTED )) -[ "$DELIVERY_ELAPSED" -le 2 ] \ - || fail "the first escalation took ${DELIVERY_ELAPSED}s to appear instead of one 1s housekeeping cadence" sleep 3 if [ "$(token_count "$ESCALATION_ONE")" -ne 1 ] || [ "$(token_count "$AWAY_ACK_TOKEN")" -ne 1 ]; then echo "first-delivery diagnostics: token-count=$(token_count "$ESCALATION_ONE") ack-count=$(token_count "$AWAY_ACK_TOKEN") agent_status=$(agent_status) composer=$(composer_state)" >&2 @@ -439,7 +466,7 @@ if [ "$(token_count "$ESCALATION_ONE")" -ne 1 ] || [ "$(token_count "$AWAY_ACK_T fi ! delivery_has_undelivered "$STATE_DIR" \ || fail "the first escalation buffer did not clear after confirmed submission" -pass "real Herdr $HERDR_VERSION + Claude $CLAUDE_VERSION: native working with rendered-idle empty composer submits once" +pass "real Herdr $HERDR_VERSION + Claude $CLAUDE_VERSION ($CLAUDE_FOOTER_MODE): native idle 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(150)' and then reply exactly $FOREGROUND_TOKEN and nothing else." \ @@ -489,7 +516,7 @@ 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" -pass "real Herdr $HERDR_VERSION + Claude $CLAUDE_VERSION: rendered-busy and pending-composer deferrals preserve human text" +pass "real Herdr $HERDR_VERSION + Claude $CLAUDE_VERSION ($CLAUDE_FOOTER_MODE): rendered-busy and pending-composer deferrals preserve human text" -printf 'evidence: session=%s native=working rendered=idle composer=empty wrapped-footer=1 delivery-seconds=%s delivered_once=1 rendered-busy=1 native-state=working=1 composer=pending=1\n' \ - "$HERDR_LAB_SESSION" "$DELIVERY_ELAPSED" +printf 'evidence: session=%s permission-mode=%s native=idle rendered=idle composer=empty stable-footer-composer=%s delivery-ms=%s delivery-bound-ms=%s delivered_once=1 rendered-busy=1 native-state=working=1 composer=pending=1\n' \ + "$HERDR_LAB_SESSION" "$CLAUDE_PERMISSION_MODE" "$STABLE_IDLE_CAPTURES" "$DELIVERY_ELAPSED_MS" "$DELIVERY_BOUND_MS" diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 275474c747e..bacc4f5d6b1 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -1788,7 +1788,7 @@ TS expected_notifications=$4 local session_arg=${5:-} local shape=${6:-single} - local extensions + local extensions answer_count tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true if [ "$calm_state" = absent ]; then @@ -1836,7 +1836,20 @@ TS fi pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) - [ "$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true)" -eq 1 ] \ + i=0 + answer_count=0 + while [ "$i" -lt 120 ]; do + answer_count=$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true) + if [ "$answer_count" -eq 1 ]; then + sleep 0.1 + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + answer_count=$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true) + [ "$answer_count" -eq 1 ] && break + fi + sleep 0.05 + i=$((i + 1)) + done + [ "$answer_count" -eq 1 ] \ || fail "Pi follow-up $label case rendered a duplicate captain answer" assert_contains "$pane" "CAPTAIN_PROMPT_$label" "Pi follow-up $label case hid the genuine captain prompt" assert_contains "$pane" "MONITOR_HANDLED_${label}_ONE" "Pi follow-up $label case did not render the intended processing result" diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index b72a8dd7b25..4b3ccbbd9d6 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -840,3 +840,58 @@ test_claude_current_footer_requires_selected_composer_adjacency() { } test_claude_current_footer_requires_selected_composer_adjacency + +test_claude_2_1_263_titled_rule_and_permission_modes() { + local fixture_root="$ROOT/tests/fixtures/claude-2.1.263-herdr-composer" + local caps=$'styled=0\ncursor=0\nidentity=0\nrows=12' + local idle busy ordinary mode screen rc verdict + idle=$(cat "$fixture_root/auto-mode-idle.txt") + busy=$(cat "$fixture_root/auto-mode-busy.txt") + + verdict=$(fm_composer_classify_screen "$caps" "$idle") + [ "$verdict" = empty ] \ + || fail "Claude 2.1.263 titled-rule idle composer must read empty, got '$verdict'" + if printf '%s\n' "$idle" | fm_claude_current_footer_busy "$caps"; then + fail "Claude 2.1.263 auto-mode idle footer must not read rendered-busy" + else + rc=$? + fi + [ "$rc" -eq 1 ] \ + || fail "Claude 2.1.263 auto-mode idle footer must read idle, got rc=$rc" + + printf '%s\n' "$busy" | fm_claude_current_footer_busy "$caps" \ + || fail "Claude 2.1.263 auto-mode active footer must read rendered-busy" + + ordinary=$(printf '%s\n' "$idle" | awk '{ if ($0 ~ /⏵⏵ auto mode on/) print "Deployment…"; else print }') + [ "$ordinary" != "$idle" ] || fail "ordinary ellipsis fixture did not replace the Claude status footer" + if printf '%s\n' "$ordinary" | fm_claude_current_footer_busy "$caps"; then + fail "an ordinary ellipsis row without a duration must not read rendered-busy" + else + rc=$? + fi + [ "$rc" -eq 1 ] \ + || fail "an ordinary ellipsis row without a duration must stay idle, got rc=$rc" + + for mode in 'bypass permissions' 'auto mode' 'accept edits' 'plan mode'; do + screen=${idle/'auto mode'/"$mode"} + if printf '%s\n' "$screen" | fm_claude_current_footer_busy "$caps"; then + fail "Claude permission footer '$mode on' must remain idle without an active row" + else + rc=$? + fi + [ "$rc" -eq 1 ] \ + || fail "Claude permission footer '$mode on' must be readable, got rc=$rc" + done + + screen=${idle/$' ⏵⏵ auto mode on (shift+tab to cycle) · ← 1 agent'/$' ? for shortcuts'} + if printf '%s\n' "$screen" | fm_claude_current_footer_busy "$caps"; then + fail "Claude bare shortcuts footer must not read rendered-busy" + else + rc=$? + fi + [ "$rc" -eq 1 ] \ + || fail "Claude bare shortcuts footer must read idle, got rc=$rc" + pass "fm_claude_current_footer_busy: Claude 2.1.263 titled-rule composers and permission-mode footers stay readable" +} + +test_claude_2_1_263_titled_rule_and_permission_modes diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index f19e12adb70..db2cf45d1d9 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -117,9 +117,11 @@ install_guard_scripts() { cp "$ROOT/bin/fm-supervision-lib.sh" "$dir/bin/fm-supervision-lib.sh" cp "$ROOT/bin/fm-wake-lib.sh" "$dir/bin/fm-wake-lib.sh" cp "$ROOT/bin/fm-hook-host-lib.sh" "$dir/bin/fm-hook-host-lib.sh" + # shellcheck disable=SC2016 # Generated launcher expands these variables when invoked. + printf '#!/usr/bin/env bash\n: > "${FM_STATE_OVERRIDE:-$FM_HOME/state}/.afk-relaunch-attempt"\nexit 1\n' > "$dir/bin/fm-afk-launch.sh" mkdir -p "$dir/docs" cp -R "$ROOT/docs/supervision-protocols" "$dir/docs/supervision-protocols" - chmod +x "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-operational-input.sh" "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-harness.sh" + chmod +x "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-grok.sh" "$dir/bin/fm-operational-input.sh" "$dir/bin/fm-supervision-instructions.sh" "$dir/bin/fm-harness.sh" "$dir/bin/fm-afk-launch.sh" } mark_codex_hook_root() { @@ -1843,8 +1845,105 @@ test_hook_away_mode_blocks_on_dead_daemon() { record_daemon_lock "$dir" "$dead" "dead daemon identity" out=$(run_hook "$dir" false); status=$? expect_code 2 "$status" "a daemon lock left by a dead daemon must not satisfy supervision" + [ -e "$dir/state/.afk-relaunch-attempt" ] \ + || fail "a dead away daemon must trigger the lifecycle launcher's recovery path" assert_contains "$out" "$AWAY_REQUIRED_REASON" "away-mode block must point at the daemon, not normal supervision" - pass "fm-turnend-guard: away mode blocks on a dead away-mode daemon" + pass "fm-turnend-guard: away mode attempts relaunch and blocks if dead-daemon recovery fails" +} + +test_hook_away_mode_does_not_trust_live_watcher_when_daemon_dead() { + local dir dead pid identity out status + dir=$(make_away_home_between_cycles "$TMP_ROOT/hook-afk-dead-daemon-live-watcher") + dead=$(nonexistent_pid) + record_daemon_lock "$dir" "$dead" "dead daemon identity" + sleep 60 & + pid=$! + identity=$(watcher_identity "$dir" "$pid") || { + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "could not identify live watcher holder" + } + record_watcher_lock "$dir" "$pid" "$identity" + out=$(run_hook "$dir" false); status=$? + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + expect_code 2 "$status" "a live watcher must not hide a dead away daemon" + [ -e "$dir/state/.afk-relaunch-attempt" ] \ + || fail "a dead away daemon must trigger relaunch even when its watcher remains live" + assert_contains "$out" "$AWAY_REQUIRED_REASON" "away-mode block must point at the daemon, not normal supervision" + pass "fm-turnend-guard: dead away-daemon ownership is checked before a live watcher can allow" +} + +test_hook_away_mode_relaunches_dead_daemon() { + local dir dead pid identity out status + dir=$(make_away_home_between_cycles "$TMP_ROOT/hook-afk-relaunch-dead-daemon") + dead=$(nonexistent_pid) + record_daemon_lock "$dir" "$dead" "dead daemon identity" + sleep 60 & + pid=$! + identity=$(watcher_identity "$dir" "$pid") || { + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "could not identify replacement away-mode daemon holder" + } + printf '%s\n' "$pid" > "$dir/state/.replacement-daemon-pid" + printf '%s\n' "$identity" > "$dir/state/.replacement-daemon-identity" + # shellcheck disable=SC2016 # Generated launcher expands these variables when invoked. + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -u' \ + 'state=${FM_STATE_OVERRIDE:-$FM_HOME/state}' \ + ': > "$state/.afk-relaunch-attempt"' \ + 'mkdir -p "$state/.supervise-daemon.lock"' \ + 'cat "$state/.replacement-daemon-pid" > "$state/.supervise-daemon.lock/pid"' \ + 'cat "$state/.replacement-daemon-identity" > "$state/.supervise-daemon.lock/pid-identity"' \ + 'sleep 1' \ + 'touch "$state/.last-watcher-beat"' \ + > "$dir/bin/fm-afk-launch.sh" + chmod +x "$dir/bin/fm-afk-launch.sh" + + out=$(run_hook "$dir" false); status=$? + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + expect_code 0 "$status" "a successful dead-daemon relaunch must restore away supervision" + [ -z "$out" ] || fail "successful away-daemon relaunch produced a block banner: $out" + [ -e "$dir/state/.afk-relaunch-attempt" ] \ + || fail "the dead-daemon relaunch path did not invoke fm-afk-launch.sh" + pass "fm-turnend-guard: a turn boundary relaunches a dead away daemon through fm-afk-launch.sh" +} + +test_hook_away_mode_blocks_until_relaunched_daemon_beats() { + local dir dead pid identity out status + dir=$(make_away_home_between_cycles "$TMP_ROOT/hook-afk-relaunch-no-beat") + dead=$(nonexistent_pid) + record_daemon_lock "$dir" "$dead" "dead daemon identity" + sleep 60 & + pid=$! + identity=$(watcher_identity "$dir" "$pid") || { + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fail "could not identify replacement away-mode daemon holder" + } + printf '%s\n' "$pid" > "$dir/state/.replacement-daemon-pid" + printf '%s\n' "$identity" > "$dir/state/.replacement-daemon-identity" + # shellcheck disable=SC2016 # Generated launcher expands these variables when invoked. + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -u' \ + 'state=${FM_STATE_OVERRIDE:-$FM_HOME/state}' \ + ': > "$state/.afk-relaunch-attempt"' \ + 'mkdir -p "$state/.supervise-daemon.lock"' \ + 'cat "$state/.replacement-daemon-pid" > "$state/.supervise-daemon.lock/pid"' \ + 'cat "$state/.replacement-daemon-identity" > "$state/.supervise-daemon.lock/pid-identity"' \ + > "$dir/bin/fm-afk-launch.sh" + chmod +x "$dir/bin/fm-afk-launch.sh" + + out=$(run_hook "$dir" false); status=$? + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + expect_code 2 "$status" "a relaunched daemon without a post-launch heartbeat must block" + assert_contains "$out" "$AWAY_REQUIRED_REASON" "away-mode block must point at the daemon, not normal supervision" + pass "fm-turnend-guard: dead-daemon relaunch requires a post-launch heartbeat" } test_hook_away_mode_blocks_on_pid_reused_daemon() { @@ -1975,6 +2074,9 @@ test_hook_away_daemon_allows_between_watcher_cycles test_hook_away_daemon_allows_over_dead_watcher_lock test_hook_away_mode_blocks_without_any_supervisor test_hook_away_mode_blocks_on_dead_daemon +test_hook_away_mode_does_not_trust_live_watcher_when_daemon_dead +test_hook_away_mode_relaunches_dead_daemon +test_hook_away_mode_blocks_until_relaunched_daemon_beats test_hook_away_mode_blocks_on_pid_reused_daemon test_hook_away_mode_blocks_on_stale_beacon test_hook_daemon_lock_is_ignored_without_away_mode diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index ef5d8fa5e6a..8db6ce3f2c3 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -31,7 +31,9 @@ test_concurrent_append_and_drain() { pids="$pids $!" i=$((i + 1)) done - FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out1" & + # Keep this fixture focused on append/drain overlap rather than the drain's + # separate presentation deadline when the watcher family is CPU-contended. + FM_STATE_OVERRIDE="$state" FM_STATUS_PRESENTATION_LOCK_TIMEOUT=30 "$DRAIN" > "$out1" & pids="$pids $!" for pid in $pids; do wait "$pid" || fail "concurrent append/drain subprocess failed"