diff --git a/AGENTS.md b/AGENTS.md index 7a855daf62a..973cdbc944a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ state/ volatile runtime signals; gitignored .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .watch.lock .wake-queue.lock watcher singleton and queue serialization locks - .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch + .hash-* .count-* .stale-* .stale-since-* .paused-* .pause-handoff-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 31de7c18ca2..a33e77562d5 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -730,7 +730,9 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # Known bare (unbordered) prompt glyphs a composer row may start with: ❯ # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Alternation, not a bracket expression, keeps byte-wise C-locale matching +# from accepting a box corner that shares the glyphs' leading byte. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} fm_backend_herdr_composer_state() { # -> empty|pending|unknown local target=$1 cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped @@ -782,7 +784,7 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 65820e6e789..16b69f7b28f 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -7,11 +7,11 @@ # overlapping triage policy lives in one place instead of two copies that can # drift apart. # -# Most functions are pure, side-effect-free reads of status files: each takes -# what it needs as arguments and touches no globals beyond the optional -# FM_CAPTAIN_RE override. Consumers layer their own dedup/marker state on top (the -# daemon keeps its escalation-digest seen-markers; the watcher keeps its .seen-* -# signatures). +# Most functions are pure, side-effect-free reads of status files or the +# watcher-owned pause handoff record: each takes what it needs as arguments and +# touches no globals beyond the optional FM_CAPTAIN_RE override. Consumers layer +# their own dedup/marker state on top (the daemon keeps its escalation-digest +# seen-markers; the watcher keeps its .seen-* signatures). # # The one exception is the absorb classification (crew_absorb_class and its # working/paused wrappers). It is NOT a pure status-file read: it reuses @@ -224,6 +224,28 @@ signal_reason_is_actionable() { # ... return 1 } +# Return the watcher-owned, short-lived coalesced-pause handoff path for one +# task, so successor stale triage can distinguish a pause that was surfaced in +# an exiting signal batch from an arbitrary old paused status event. +crew_pause_handoff_file() { # [state-dir] + local id=$1 state=${2:-${STATE:-${FM_STATE_OVERRIDE:-}}} + [ -n "$id" ] && [ -n "$state" ] || return 1 + printf '%s/.pause-handoff-%s' "$state" "$id" +} + +# Accept a handoff only when it is a regular file whose recorded final status +# still exactly matches the task's final paused event; this prevents recovery +# from masking a newer status transition or following a symlinked artifact. +crew_pause_handoff_allows_recovery() { # [state-dir] + local id=$1 state=${2:-${STATE:-${FM_STATE_OVERRIDE:-}}} marker last recorded + marker=$(crew_pause_handoff_file "$id" "$state") || return 1 + [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 + last=$(last_status_line "$state/$id.status") + status_is_paused "$last" || return 1 + IFS= read -r recorded < "$marker" || return 1 + [ "$recorded" = "$last" ] +} + # Classify WHY an idle/stale crew MIGHT be safely absorbed instead of surfaced, # from bin/fm-crew-state.sh's one authoritative current-state line # ("state: · source: · "). Prints exactly one token: @@ -251,6 +273,13 @@ crew_absorb_class() { # src=${line#*source: }; src=${src%% *} case "$src" in run-step|pane) printf 'working'; return ;; esac fi + if [ "$state" = unknown ]; then + src=${line#*source: }; src=${src%% *} + if [ "$src" = none ] && crew_pause_handoff_allows_recovery "$id"; then + printf 'paused' + return + fi + fi printf 'none' } diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 437b8c68977..e94b39a2652 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -207,8 +207,18 @@ fm_composer_classify_content() { # [idle_re] [idle_case] [ fi # Strip a leading prompt glyph, then re-judge the remainder. case "$content" in - '❯ '*|'› '*|'> '*|'$ '*|'% '*|'# '*) content=${content#??} ;; - '❯'*|'›'*|'>'*|'$'*|'%'*|'#'*) content=${content#?} ;; + '❯ '*) content=${content#'❯ '} ;; + '› '*) content=${content#'› '} ;; + '> '*) content=${content#'> '} ;; + '$ '*) content=${content#'$ '} ;; + '% '*) content=${content#'% '} ;; + '# '*) content=${content#'# '} ;; + '❯'*) content=${content#'❯'} ;; + '›'*) content=${content#'›'} ;; + '>'*) content=${content#'>'} ;; + '$'*) content=${content#'$'} ;; + '%'*) content=${content#'%'} ;; + '#'*) content=${content#'#'} ;; esac content="${content#"${content%%[![:space:]]*}"}" content="${content%"${content##*[![:space:]]}"}" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 13b895f003d..bb79cda1b35 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -361,7 +361,9 @@ pause_state_class() { # fi class=$(crew_absorb_class "$task") case "$class" in - paused) date +%s > "$recheck_file" ;; + paused) date +%s > "$recheck_file" + rm -f "$(crew_pause_handoff_file "$task" "$STATE")" + ;; *) rm -f "$recheck_file" ;; esac printf '%s' "$class" @@ -406,6 +408,26 @@ scan_signals() { return 0 } +# Persist final paused events from a signal batch that will make this watcher +# exit, allowing its successor to rebuild pause tracking when no live +# current-state source is available. +record_coalesced_pause_handoffs() { # + local pending=$1 sf sig f last task + while IFS=$(printf '\t') read -r sf sig f; do + [ -n "$f" ] || continue + case "$f" in + *.status) + last=$(last_status_line "$f") + status_is_paused "$last" || continue + task=$(basename "$f"); task=${task%.status} + printf '%s\n' "$last" > "$(crew_pause_handoff_file "$task" "$STATE")" + ;; + esac + done </dev/null 2>&1; then @@ -692,6 +714,7 @@ EOF done <` with that task's final non-blank status line before it exits. +A successor may rebuild pause tracking from that handoff only when `bin/fm-crew-state.sh` reports `unknown` with source `none` and the current final status line exactly matches the recorded line. +After rebuilding the pause marker, stale triage consumes the handoff, and any later non-paused final status event clears it. Fresh stale panes use the same current-state read before trusting the status log, so an active run or busy pane outranks an old captain-relevant status-log line left behind before validation. No-change heartbeats are also benign. Absorbed wakes advance their suppression markers, log to `state/.watch-triage.log`, and keep the watcher blocking without a queue record or LLM turn. diff --git a/docs/configuration.md b/docs/configuration.md index d0d76ff7c93..888f27ca30d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -331,7 +331,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)") +FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; alternation prevents a non-UTF-8 locale from byte-wise matching a box corner with the same leading byte; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Native agent-state submit confirmation") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline FM_BACKEND_ORCA_COMPOSER_LINES=200 # orca-only: terminal-read lines scanned to locate the composer row for submit verification diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 877a9352720..810a99f9a8d 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -809,7 +809,7 @@ Covered by the unit cases in `tests/fm-afk-launch.test.sh` (clear-on-fresh-entry If a future herdr build strips ANSI style from `--format ansi`, the classifier loses its ghost signal and falls back to reading the suggestion text as `pending` - the fail-safe direction (it defers rather than risks overwriting a human draft), which the max-defer alarm then surfaces. - **RESOLVED: a "paused / awaiting-external" crew state for the stale-wedge escalation.** Raised alongside the 2026-07-07 incident: an in-flight crew intentionally idling on a known external wait (a vendor rate limit, say) still tripped `bin/fm-supervise-daemon.sh`'s "stale persisted ... (possible wedge)" escalation exactly like a genuinely wedged crew, with no way to mark the wait as expected. Fixed by the `paused:` external-wait verb: a crew declares a deliberate wait, and both `bin/fm-watch.sh` and `bin/fm-supervise-daemon.sh` absorb its idle pane through the shared `bin/fm-classify-lib.sh` vocabulary (`status_is_paused`, `crew_absorb_class`, `FM_PAUSE_RESURFACE_SECS`), re-surfacing it for a recheck on a long cadence instead of a wedge escalation. - See `AGENTS.md` section 8 and the crew-facing brief contract in `bin/fm-brief.sh`. + See `AGENTS.md` section 8, the [event-driven supervision contract](architecture.md#event-driven-supervision) for coalesced-signal pause recovery, and the crew-facing brief contract in `bin/fm-brief.sh`. - **Not implemented: mid-session secondmate liveness.** The `fm_backend_agent_alive`-driven respawn sweep (`bin/fm-bootstrap.sh`, see "Agent liveness probe reuses the husk classifier" above) only runs at session start. A secondmate dying mid-session is a harder follow-on: the watcher deliberately exempts secondmates from stale-pane detection (an idle secondmate pane is healthy by design), so catching a mid-session death would need a periodic liveness beacon distinct from that exemption, not implemented here. Deferred as a separate item - it changes the stale-classification/status vocabulary shared with `bin/fm-watch.sh` and `bin/fm-classify-lib.sh`, which is a bigger surface than this redelivery-loop fix should carry. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 678607655a3..aefe10fb717 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -328,7 +328,7 @@ test_create_task_refuses_duplicate_label() { dir="$TMP_ROOT/dup-task"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" printf '{"result":{"tabs":[{"tab_id":"w1:t2","label":"fm-dup1","workspace_id":"w1"}]}}\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") - out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + out=$( LC_ALL=C PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_create_task fmtest:w1 fm-dup1 /tmp/proj' "$ROOT" 2>&1 ) status=$? [ "$status" -ne 0 ] || fail "create_task should refuse an existing tab label (herdr itself does not enforce uniqueness)" diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 53cb8311478..a620b56778c 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -97,7 +97,7 @@ test_idle_placeholder_is_empty() { out=$(classify 1 'Type a message...' "$idle") [ "$out" = empty ] || fail "the grok idle placeholder should read empty, got '$out'" # Placeholder after an agent glyph (post-strip match). - out=$(classify 0 '❯ Type a message...' "$idle") + out=$(LC_ALL=C classify 0 '❯ Type a message...' "$idle") [ "$out" = empty ] || fail "the idle placeholder after a glyph should read empty, got '$out'" # Without the idle regex it is just text -> pending. out=$(classify 1 'Type a message...') diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index e97f014039a..22ff02bffe1 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -219,7 +219,11 @@ test_crew_absorb_class_classifier() { [ "$(crew_absorb_class a)" = none ] || fail "unknown crew classed absorbable" ! crew_is_paused a || fail "unknown crew classed paused" printf 'paused: awaiting upstream\n' > "$state/a.status" - [ "$(STATE="$state" crew_absorb_class a)" = none ] || fail "unknown/none with a stale paused status was suppressed" + [ "$(STATE="$state" crew_absorb_class a)" = none ] || fail "unknown/none trusted an old paused status without a handoff" + printf 'paused: awaiting upstream\n' > "$state/.pause-handoff-a" + [ "$(STATE="$state" crew_absorb_class a)" = paused ] || fail "unknown/none did not recover a matching coalesced pause handoff" + printf 'paused: changed reason\n' > "$state/a.status" + [ "$(STATE="$state" crew_absorb_class a)" = none ] || fail "unknown/none trusted a stale coalesced pause handoff" [ "$(crew_absorb_class "")" = none ] || fail "empty id not classed none" unset FM_FAKE_CREW_STATE pass "crew_absorb_class: working/paused/none from one read; crew_is_paused and crew_is_provably_working agree" @@ -632,6 +636,129 @@ test_settled_pause_survives_watcher_restart_without_pause_marker() { pass "a settled declared pause survives watcher restart without a .paused marker" } +# A pause can land in the same grace-window batch as a different actionable +# signal. +# That first watcher must surface the batch and exit before its stale loop runs, +# so it cannot create .paused-*. +# The next watcher must rebuild pause tracking from the durable final paused event +# rather than surface a bare stale wake on every re-arm while the current-state +# reader is temporarily unavailable. +test_coalesced_pause_signal_rebuilds_stale_pause_tracking() { + local dir state fakebin out drain_out capture_file statusf window key pane_hash pid + dir=$(make_case coalesced-pause-signal); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; drain_out="$dir/drain.out"; capture_file="$dir/pane.txt"; window="test:fm-coalesced-pause" + printf 'idle, awaiting upstream\n' > "$capture_file" + printf 'window=%s\nkind=ship\n' "$window" > "$state/held.meta" + statusf="$state/held.status" + printf 'paused: awaiting the upstream release\n' > "$statusf" + printf 'done: sibling task needs captain review\n' > "$state/sibling.status" + : > "$state/sibling.turn-ended" + key=${window//:/_} + key=${key//\//_} + key=${key//./_} + pane_hash=$(hash_text 'idle, awaiting upstream') + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: unknown · source: none · no current-state result during watcher handoff' + + # The pause and sibling terminal signal coalesce. + # The watcher must surface the batch and exit, leaving stale pause tracking to + # its successor. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "coalesced pause/sibling signal did not surface" + grep -F "signal:" "$out" >/dev/null || fail "coalesced pause/sibling signal did not print a signal wake" + [ ! -e "$state/.paused-$key" ] || fail "first watcher unexpectedly reached stale pause tracking before its signal exit" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after coalesced pause/sibling signal failed" + [ -f "$state/.pause-handoff-held" ] || fail "coalesced pause signal did not leave a successor handoff" + [ "$(STATE="$state" crew_absorb_class held)" = paused ] || fail "coalesced pause handoff did not classify the successor as paused" + + # Signal markers now suppress the already-surfaced batch. + # The stale path must use the durable pause tail to create the marker and stay + # quiet. + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid" + fail "stale path surfaced a bare wake after a coalesced pause signal: $(cat "$out")" + fi + [ ! -s "$out" ] || { reap "$pid"; fail "stale path printed a wake after a coalesced pause signal: $(cat "$out")"; } + [ ! -s "$state/.wake-queue" ] || { reap "$pid"; fail "stale path enqueued a bare wake after a coalesced pause signal"; } + [ -e "$state/.paused-$key" ] || { reap "$pid"; fail "stale path did not rebuild pause tracking after a coalesced pause signal"; } + [ ! -e "$state/.pause-handoff-held" ] || { reap "$pid"; fail "stale path did not consume the coalesced pause handoff"; } + reap "$pid" + unset FM_FAKE_CREW_STATE + pass "a coalesced paused signal rebuilds stale pause tracking on the successor watcher" +} + +test_initial_pause_signal_rebuilds_stale_pause_tracking() { + local dir state fakebin out capture_file statusf window key pane_hash pid + dir=$(make_case initial-pause-signal); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-initial-pause" + printf 'idle, awaiting upstream\n' > "$capture_file" + printf 'window=%s\nkind=ship\n' "$window" > "$state/held.meta" + statusf="$state/held.status" + printf 'paused: awaiting the upstream release\n' > "$statusf" + key=$(printf '%s' "$window" | tr ':/. ' '____') + pane_hash=$(hash_text 'idle, awaiting upstream') + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: unknown · source: none · no current-state result during watcher handoff' + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "initial pause signal did not surface" + [ -f "$state/.pause-handoff-held" ] || fail "initial pause signal did not leave a successor handoff" + + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid" + fail "stale path surfaced after an initial pause signal: $(cat "$out")" + fi + [ -e "$state/.paused-$key" ] || { reap "$pid"; fail "stale path did not rebuild pause tracking after an initial pause signal"; } + reap "$pid" + unset FM_FAKE_CREW_STATE + pass "an initial paused signal rebuilds stale pause tracking on the successor watcher" +} + +test_gone_paused_target_surfaces_without_coalesced_handoff() { + local dir state fakebin out drain_out capture_file statusf window key pane_hash sig pid + dir=$(make_case gone-paused-target); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; drain_out="$dir/drain.out"; capture_file="$dir/pane.txt"; window="test:fm-gone-paused" + printf 'idle, old pause remains\n' > "$capture_file" + printf 'window=%s\nkind=ship\n' "$window" > "$state/gone-paused.meta" + statusf="$state/gone-paused.status" + printf 'paused: awaiting an upstream release\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-gone-paused_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text 'idle, old pause remains') + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: unknown · source: none · worktree gone' + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "gone paused target was suppressed instead of surfaced" + grep -Fx "stale: $window" "$out" >/dev/null || fail "gone paused target did not print an immediate stale wake" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after gone paused target failed" + grep "$(printf '\tstale\t')" "$drain_out" | grep -F "$window" >/dev/null || fail "gone paused target stale wake was not queued" + unset FM_FAKE_CREW_STATE + pass "a gone target with an old paused status surfaces without a coalesced handoff" +} + test_secondmate_paused_resurfaces_in_normal_mode() { local dir state fakebin out capture_file statusf window key pane_hash sig pid back dir=$(make_case secondmate-paused-resurface); state="$dir/state"; fakebin="$dir/fakebin" @@ -1159,6 +1286,9 @@ test_wedge_escalation_resets_when_pane_becomes_active test_nonterminal_stale_not_working_surfaced test_nonterminal_stale_paused_absorbed_then_resurfaced test_settled_pause_survives_watcher_restart_without_pause_marker +test_coalesced_pause_signal_rebuilds_stale_pause_tracking +test_initial_pause_signal_rebuilds_stale_pause_tracking +test_gone_paused_target_surfaces_without_coalesced_handoff test_secondmate_paused_resurfaces_in_normal_mode test_secondmate_nonpaused_stale_remains_suppressed test_secondmate_unpause_clears_pause_tracking