diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 2cb290373c..41579d6410 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -16,7 +16,7 @@ # fixed mapping logic, no heuristics and no LLM. Output is one stable, parseable, # token-tight line firstmate can read every heartbeat: # -# state: · source: · +# state: · source: · # # Logic, in order: # 1. Resolve worktree + backend target + kind from state/.meta. @@ -39,13 +39,19 @@ # the run-step shows the run moved on, the log is deterministically stale and # is flagged superseded. A genuinely parked run plus a needs-decision log # agree, and are reported as parked. -# 4. No run for this crew (pre-validation, or kind=scout): fall back to the +# 4. No run for this crew (pre-validation, or kind=scout): consult the +# recovery-grade agent-state classifier (fm_backend_agent_state, owned by +# bin/fm-backend.sh) first; a CONFIDENT dead/missing verdict reports +# unknown · agent-state instead of trusting a frozen busy-hook read or the +# status log - closes the incident where a harness process died but its +# pane/shell kept answering as "working" forever. Any other verdict +# (unverified/unreadable/ambiguous) changes nothing: fall back to the # recorded backend's pane busy state, then the status log's last line only # when its verb maps to a recognized run-state. Decision-only events such as # `resolved` never become current state or detail. -# 5. Missing meta or torn-down worktree: report unknown · none. If no run is -# attributed to this crew, a dead endpoint also reports unknown · none rather -# than trusting a stale status log. +# 5. Missing meta or torn-down worktree: report unknown · none. An unreadable +# backend target (pane/session itself gone) also reports unknown · none; +# see 4 for the agent-state check applied while the target still answers. # # Read-only and side-effect free. Always exits 0 on a successful read regardless # of state; exit 2 only on a usage error (no id). @@ -539,6 +545,27 @@ fi [ -n "$BACKEND_TARGET" ] || emit unknown none "no backend target recorded" pane_readable "$BACKEND_TARGET" || emit unknown none "backend target gone: $BACKEND_TARGET" +# Recovery-grade agent liveness, ahead of both the busy-hook read and the +# status-log fallback below: pane_readable only proves the pane/shell answers, +# not that a harness process is actually running in it, and both +# crew_busy_verdict (frozen hook state) and the status log's last line can keep +# reading "working" forever once that process is dead while its shell survives. +# fm_backend_agent_state is the one owner of this question (bin/fm-backend.sh; +# already used by fm-control.sh, fm-spawn.sh, fm-bootstrap.sh, fm-watch.sh, +# fm-stow-cascade.sh) - reused here rather than forking a second classifier. +# Only a CONFIDENT dead/missing verdict changes behavior: unverified (backend +# has no classifier - today only tmux and herdr do), unreadable, and ambiguous +# all fall through with zero effect, byte-identical to before this check +# existed, because a false "crew is dead" would trigger recovery against a live +# worker - worse than the silent-working gap this closes. Cost is one bounded +# backend-native call (a handful of local tmux/ps reads, or one herdr API call) +# added to the no-run fallback path only, never the run-step path above and +# never a network call. +AGENT_STATE=$(fm_backend_agent_state "$TASK_BACKEND" "$BACKEND_TARGET" 2>/dev/null) || AGENT_STATE=unreadable +case "$AGENT_STATE" in + dead|missing) emit unknown agent-state "harness process confirmed $AGENT_STATE: $BACKEND_TARGET" ;; +esac + # Secondmates idle on their own watcher (idle pane = healthy), so the busy # state is not meaningful for them; read their state from the status log only. # Only an exact busy verdict reports working here, and only an exact idle diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index bc7f1a3c47..14cba26b31 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -30,8 +30,13 @@ # against current_state; hints.pending_decision and hints.blocked_event are # booleans derived from that set. # endpoint.exists is the cheap backend endpoint-presence read. -# endpoint.agent_alive is populated for secondmates only, where it is useful -# return-channel supervision data; other tasks use "not_checked". +# endpoint.agent_alive is the recovery-grade agent-state verdict +# (fm_backend_agent_alive, bin/fm-backend.sh) for every task with a recorded +# target, remote or local: alive/dead when the backend has a confident +# classifier, else "unknown" (no classifier for this backend, or an +# inconclusive read) - never a false dead. A remote secondmate target reuses +# its own alive/dead/missing/other mapping above instead of this call. +# "not_checked" appears only when the task has no recorded target at all. # scout_reports[]: present data//report.md pointers. # main_inventory: {valid,reason,orphan_in_flight[],unstructured_current_count} - # main-home current-inventory checks shared with secondmate_home_summary_json @@ -510,7 +515,7 @@ task_json_lines() { endpoint_exists=false fi fi - if [ "$kind" = secondmate ] && [ -n "$target" ]; then + if [ -n "$target" ]; then agent_alive=$(fm_backend_agent_alive "$backend" "$target" 2>/dev/null || printf unknown) fi fi diff --git a/bin/fm-push-transition-lib.sh b/bin/fm-push-transition-lib.sh index 5ee55fd3b4..442ea6b14f 100644 --- a/bin/fm-push-transition-lib.sh +++ b/bin/fm-push-transition-lib.sh @@ -129,6 +129,21 @@ handle_push_transition() { # to=$(fm_transition_to_status "$record") [ -n "$pane_id" ] || { sleep 1; return; } window="$session:$pane_id" + # The raw stream drain (bin/backends/herdr.sh's fm_backend_herdr_wait_transition) + # forwards whatever edge the reader delivers - it is not re-filtered against the + # caller's own subscribed pane list - so a torn-down task's former pane, or its + # surviving husk shell (crew-exit-probe incident, 2026-08-16: the harness process + # died but the pane/shell kept answering), can keep delivering edges long after + # teardown removed its state/.meta. Recorded-task membership is the one + # authoritative test (matches recorded_windows' own poll-path filter): commit the + # transition so herdr's own escalation marker still advances - the same edge is + # never re-evaluated as fresh on a later drain - then absorb silently. A live, + # currently-recorded task is completely unaffected: this check is a no-op for it. + if ! fm_backend_meta_for_window "$window" "$STATE" >/dev/null; then + fm_backend_commit_transition "$backend" "$STATE" "$session" "$record" || exit 1 + triage_log "absorbed push $to (no recorded task for this pane - retired or unknown endpoint): $window" + return + fi task=$(window_to_task "$window" "$STATE") if status_is_paused "$(last_status_line "$STATE/$task.status")"; then triage_log "absorbed push $to (declared pause, awaiting external): $window" diff --git a/docs/architecture.md b/docs/architecture.md index ee6749827f..62ed777850 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ Any direct or remaining historical annotation prints every status line unread at The script header owns the exact run-head ancestry rules. During no-mistakes' `ci` monitor phase, it also reads the ci step log tail because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. The most recent recognized ci log marker wins, so checks-green monitoring reports done while a later re-arm, failed-check, or issue marker returns the crew to working. -Only when no matching run exists does it consult semantic busy state; exact busy reports working, exact idle permits fallback to a status-log event whose verb maps to a recognized run-state, and unknown or a dead pane stays unknown instead of trusting a stale log. +Only when no matching run exists does it consult the recovery-grade agent-state classifier first, ahead of semantic busy state: a confident dead or missing verdict reports unknown before busy state is even read, closing the case where a harness process died but its pane kept answering as busy. Any other verdict falls through unchanged: exact busy reports working, exact idle permits fallback to a status-log event whose verb maps to a recognized run-state, and unknown or a dead pane stays unknown instead of trusting a stale log. Decision-only events such as `resolved` never become current state or leak their prose into the current-state detail. In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason. The semantic branch reports working only on an exact busy verdict and names the source that produced it; an unknown verdict never becomes working, never permits the status-log fallback, and never becomes a silent idle. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 4c75fd8bc5..eb84d68ad5 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -271,7 +271,7 @@ Mid-session secondmate liveness is not implemented because idle secondmates are Protocol 16 can subscribe to `pane.agent_status_changed` over one bounded Unix-socket reader. `bin/fm-transition-lib.sh` owns the backend-neutral transition vocabulary and policy. The Herdr adapter subscribes before reconciling current levels, buffers edges during reconciliation, and returns fresh blocked transitions for this home's panes. -The watcher maps the pane back to the task and skips secondmate endpoints and declared `paused:` waits. +The watcher maps the pane back to the task and skips secondmate endpoints, declared `paused:` waits, and any pane no task currently records (a torn-down task's former pane, or one Firstmate never owned). The push path only shortens latency. Polling runs every cycle and remains the permanent fallback when protocol 16, the event schema, Python, connection, subscription, or repeated reader execution is unavailable. diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index e955608fdb..2992c02d08 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -41,6 +41,14 @@ case "${1:-}" in *) printf 'all quiet\n> \n' ;; esac ;; + list-windows) + # fm_backend_agent_alive's session inventory (bin/fm-fleet-snapshot.sh's + # endpoint.agent_alive, now populated for every kind, not just secondmates). + # This fixture has no real tmux session behind it, so fail generically - + # NOT one of the classifier's recognized missing-session/server/socket + # patterns - so it reads unreadable rather than a false "missing"/"dead". + echo "list-windows not configured for this fixture" >&2 + exit 1 ;; esac exit 0 SH diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 8f986b6139..459882483c 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -25,6 +25,15 @@ # This is the direct regression pair for the 2026-07-02 herdr incident, # proving the watcher's own absorb-only-when-provably-working predicate # benefits from the fix in both directions. +# (l) no run + a CONFIRMED dead/missing agent overrides a frozen busy-hook +# record or a stale "working:" status log -> agent-state, not pane +# or status-log. Regression for the crew-exit-probe incident (2026-08-16): +# a harness process died while its pane/shell kept answering, so the old +# fallback kept reporting "working" forever from whichever signal was +# last written before the process died. Every inconclusive verdict +# (ambiguous/unreadable/unverified) must fall through with NO effect - +# proven here by reproducing the exact pre-fix output for each one, not +# just asserting it stayed the same state name. set -u # shellcheck source=tests/lib.sh @@ -85,11 +94,32 @@ set -u case "${1:-}" in display-message) [ "${FM_FAKE_TMUX_MISSING:-0}" = 1 ] && exit 1 + for a in "$@"; do + case "$a" in + *pane_current_command*) + [ -n "${FM_FAKE_TMUX_AGENT_COMM:-}" ] && { printf '%s\n' "$FM_FAKE_TMUX_AGENT_COMM"; exit 0; } + break ;; + esac + done printf '%%1\n' ;; capture-pane) [ "${FM_FAKE_TMUX_MISSING:-0}" = 1 ] && exit 1 if [ "${FM_FAKE_BUSY:-0}" = 1 ]; then printf 'work in progress\n%s\n' "${FM_FAKE_BUSY_TEXT:-esc to interrupt}" else printf 'all quiet\n> \n'; fi ;; + list-windows) + # fm_backend_tmux_agent_state's own session inventory (the recovery-grade + # agent-state classifier fm-crew-state.sh's no-run fallback now consults). + # Opt in per-test with FM_FAKE_TMUX_AGENT_WINDOW to drive a specific + # alive/dead/missing verdict. Left unset, this fails with a message that + # does NOT match the classifier's missing-session/server/socket patterns, + # so it reads unreadable - the safe no-op every other fixture in this file + # relies on, since none of them care about agent-state. + if [ -n "${FM_FAKE_TMUX_AGENT_WINDOW:-}" ]; then + printf '%s\n' "$FM_FAKE_TMUX_AGENT_WINDOW" + exit 0 + fi + printf 'list-windows not configured for this fixture\n' >&2 + exit 1 ;; esac exit 0 SH @@ -170,8 +200,11 @@ reset_fakes() { FM_FAKE_HERDR_MISSING=0 FM_FAKE_HERDR_AGENT_STATUS="" FM_FAKE_CI_LOGS="" + FM_FAKE_TMUX_AGENT_WINDOW="" + FM_FAKE_TMUX_AGENT_COMM="" export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_BUSY_TEXT FM_FAKE_TMUX_MISSING export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_CI_LOGS + export FM_FAKE_TMUX_AGENT_WINDOW FM_FAKE_TMUX_AGENT_COMM } # --- run-object fixtures (TOON, as `no-mistakes axi status` emits) ----------- @@ -1084,6 +1117,159 @@ test_dead_window_still_reports_active_run_step() { pass "closed pane still reports an active run-step" } +# (l) crew-exit-probe regression: a CONFIRMED-dead harness agent (pane_readable +# still succeeds - the shell survives - but fm_backend_agent_state reads +# dead/missing) must override a frozen "working:" status log rather than +# reading it as current state. +test_fast_path_confirmed_dead_agent_overrides_stale_status_log() { + reset_fakes + local d; d=$(new_case dead-agent-status-log) + make_repo_on_branch "$d/wt" fm/feat-deadagent + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-deadagent.meta" "window=fm:fm-feat-deadagent" "worktree=$d/wt" "kind=ship" "harness=claude" + # The frozen leftover from the last thing the crew ever wrote before its + # harness process died - exactly the incident's observed status line. + printf 'working: pipeline fix round in progress\n' > "$d/state/feat-deadagent.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + # Session inventory is readable and reports the window (the shell survived), + # but the only foreground process is a bare shell -> confirmed dead, not just + # unreadable/ambiguous. + FM_FAKE_TMUX_AGENT_WINDOW="fm-feat-deadagent" + FM_FAKE_TMUX_AGENT_COMM="bash" + local out; out=$(run_crew_state "$d" feat-deadagent) + assert_not_contains "$out" "state: working" "a confirmed-dead agent must never read working from a frozen status log" + assert_contains "$out" "state: unknown" "confirmed-dead agent -> unknown" + assert_contains "$out" "source: agent-state" "confirmed-dead agent names its source distinctly" + assert_contains "$out" "dead" "the detail names the confirmed verdict" + pass "a confirmed-dead agent overrides a frozen working status log" +} + +# The more dangerous variant: the crew died mid-turn, so the LAST thing its own +# stop/prompt hook ever wrote was "busy" (source: pane), which previously +# reported working forever with no escalation path at all (pane source is what +# crew_absorb_class treats as provably working). This is the more dangerous +# case because it also feeds the watcher's absorb-only-when-provably-working +# predicate (bin/fm-classify-lib.sh's crew_absorb_class), and would otherwise +# never let a wedge timer even start. +test_fast_path_confirmed_dead_agent_overrides_frozen_busy_hook() { + reset_fakes + local d; d=$(new_case dead-agent-busy-hook) + make_repo_on_branch "$d/wt" fm/feat-deadbusy + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-deadbusy.meta" "window=fm:fm-feat-deadbusy" "worktree=$d/wt" "kind=ship" "harness=claude" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-deadbusy) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-deadbusy busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit + FM_FAKE_TMUX_AGENT_WINDOW="fm-feat-deadbusy" + FM_FAKE_TMUX_AGENT_COMM="bash" + local out; out=$(run_crew_state "$d" feat-deadbusy) + assert_not_contains "$out" "state: working" "a confirmed-dead agent must override a frozen busy hook record too" + assert_not_contains "$out" "source: pane" "a confirmed-dead agent must not be read through the pane/busy-hook source" + assert_contains "$out" "source: agent-state" "confirmed-dead agent names its source distinctly" + pass "a confirmed-dead agent overrides a frozen busy hook record" +} + +# The regression that matters most: an EXPLICITLY confirmed-alive agent (not +# merely unverified/unreadable) must change nothing - a genuinely alive-but-quiet +# worker (e.g. blocked on a long foreground call) must still read working. +test_fast_path_confirmed_alive_agent_still_reports_working() { + reset_fakes + local d; d=$(new_case alive-agent-busy) + make_repo_on_branch "$d/wt" fm/feat-aliveagent + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-aliveagent.meta" "window=fm:fm-feat-aliveagent" "worktree=$d/wt" "kind=ship" "harness=claude" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-aliveagent) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-aliveagent busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit + FM_FAKE_TMUX_AGENT_WINDOW="fm-feat-aliveagent" + FM_FAKE_TMUX_AGENT_COMM="claude" + local out; out=$(run_crew_state "$d" feat-aliveagent) + assert_contains "$out" "state: working" "an explicitly confirmed-alive agent still reads working" + assert_contains "$out" "source: pane" "an explicitly confirmed-alive agent still uses the busy-hook source" + assert_contains "$out" "claude-hook" "the working verdict still names its semantic source" + pass "an explicitly confirmed-alive agent still reports working from its busy record" +} + +# Hard requirement: only a CONFIDENT dead/missing verdict may change behavior. +# ambiguous, unreadable, and unverified (no classifier for this backend) must +# fall through with output BYTE-IDENTICAL to the pre-fix baseline - demonstrated +# here by reproducing that exact baseline (test_no_run_idle_pane_uses_log's +# scenario) under each inconclusive verdict in turn, not merely asserting the +# state name stayed the same. +test_fast_path_inconclusive_agent_state_falls_through_unchanged() { + reset_fakes + local d baseline out + d=$(new_case inconclusive-baseline) + make_repo_on_branch "$d/wt" fm/feat-inconclusive + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-inconclusive.meta" "window=fm:fm-feat-inconclusive" "worktree=$d/wt" "kind=ship" "harness=claude" + printf 'needs-decision: which database?\n' > "$d/state/feat-inconclusive.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-inconclusive + # Baseline: no agent-state fixture configured at all (list-windows fails + # generically -> unreadable), matching every other test in this file. + baseline=$(run_crew_state "$d" feat-inconclusive) + assert_contains "$baseline" "state: parked" "sanity: baseline reproduces the no-run idle-pane scenario" + + # ambiguous: session inventory readable, but the foreground process is + # neither a recognized shell nor a recognized agent name. + FM_FAKE_TMUX_AGENT_WINDOW="fm-feat-inconclusive" + FM_FAKE_TMUX_AGENT_COMM="node" + out=$(run_crew_state "$d" feat-inconclusive) + [ "$out" = "$baseline" ] || fail "an ambiguous agent-state verdict must not change the output (got: $out)" + + # unreadable: explicitly force the same generic list-windows failure the + # baseline relies on implicitly, so this case is not accidentally vacuous. + FM_FAKE_TMUX_AGENT_WINDOW="" + FM_FAKE_TMUX_AGENT_COMM="" + out=$(run_crew_state "$d" feat-inconclusive) + [ "$out" = "$baseline" ] || fail "an unreadable agent-state verdict must not change the output (got: $out)" + + pass "ambiguous and unreadable agent-state verdicts fall through byte-identical to baseline" +} + +# unverified: a backend with no recovery-grade classifier at all (per +# bin/fm-backend.sh, only tmux and herdr have one today) must also change +# nothing, proven the same way: reproduce a real no-run scenario on that +# backend and confirm the output matches what it would be without the new +# check ever running (fm_backend_agent_state short-circuits to 'unverified' +# before touching the network or the pane at all). +test_fast_path_unverified_backend_falls_through_unchanged() { + reset_fakes + local d fb; d=$(new_case unverified-backend) + make_repo_on_branch "$d/wt" fm/feat-unverified + make_fakebin "$d" >/dev/null + # orca has a real capture path (so pane_readable succeeds - the endpoint is + # readable, matching a genuinely live pane) but fm_backend_agent_state has no + # orca classifier and always answers 'unverified' for it, per bin/fm-backend.sh. + fb="$d/fakebin" + cat > "$fb/orca" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-} ${2:-}" in + "terminal read") printf '{"ok":true,"result":{"text":"quiet"}}\n'; exit 0 ;; +esac +exit 0 +SH + chmod +x "$fb/orca" + fm_write_meta "$d/state/feat-unverified.meta" "window=orcaterm1" "worktree=$d/wt" "kind=ship" \ + "harness=claude" "backend=orca" + printf 'needs-decision: which database?\n' > "$d/state/feat-unverified.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-unverified + local out; out=$(run_crew_state "$d" feat-unverified) + assert_contains "$out" "state: parked" "an unverified backend still falls back to the status log" + assert_contains "$out" "source: status-log" "an unverified backend's classifier never overrides the status log" + pass "a backend with no recovery-grade classifier falls through unchanged" +} + test_no_timeout_uses_perl_bound() { reset_fakes local d toolbin out start elapsed calls_file calls @@ -1347,6 +1533,11 @@ test_no_run_idle_secondmate_resolved_event_not_state test_dead_window_ignores_stale_status_log test_dead_window_still_reports_terminal_run_step test_dead_window_still_reports_active_run_step +test_fast_path_confirmed_dead_agent_overrides_stale_status_log +test_fast_path_confirmed_dead_agent_overrides_frozen_busy_hook +test_fast_path_confirmed_alive_agent_still_reports_working +test_fast_path_inconclusive_agent_state_falls_through_unchanged +test_fast_path_unverified_backend_falls_through_unchanged test_no_timeout_uses_perl_bound test_scout_skips_run_lookup test_torn_down_worktree diff --git a/tests/fm-supervision-events.test.sh b/tests/fm-supervision-events.test.sh index ca6c683907..126edde382 100755 --- a/tests/fm-supervision-events.test.sh +++ b/tests/fm-supervision-events.test.sh @@ -69,6 +69,35 @@ fm_write_meta "$STATE_DIR/tk1.meta" "window=default:wG:pQ" "backend=herdr" "kind [ ! -e "$STATE_DIR/.herdr-escalated-default_wG_pQ" ] || fail "a failed durable enqueue must leave the blocked edge eligible for reconnect reconciliation" pass "handle_push_transition: enqueue failure cannot commit the Herdr dedupe marker" +# --- handle_push_transition: absorb (no wake, no enqueue) for a retired pane -- +# Regression for the crew-exit-probe decision's accepted scope (2026-08-16): +# the raw event drain forwards whatever pane_id the reader delivers with no +# cross-check against the caller's own subscribed window list, so a torn-down +# task's former pane (no state/.meta left at all - the observed live +# symptom, default:w4:p3/p4 waking firstmate with nothing actionable) must +# never wake the supervisor, even though herdr itself still reports it blocked. + +reset_state +# Deliberately no fm_write_meta call: no task anywhere records this window. +handle_push_transition herdr default "$(mkrec wZ:pR blocked)" +if [ -e "$STATE_DIR/.wake-queue" ] && grep -q 'stale' "$STATE_DIR/.wake-queue"; then + fail "a pane with no recorded task must NOT enqueue a stale wake: $(cat "$STATE_DIR/.wake-queue")" +fi +[ ! -s "$WAKE_LOG" ] || fail "a pane with no recorded task must not wake the supervisor" +[ -e "$STATE_DIR/.herdr-escalated-default_wZ_pR" ] || fail "the retired-pane edge must still be committed so it is never re-evaluated as fresh" +grep -q 'absorbed push' "$STATE_DIR/.watch-triage.log" 2>/dev/null || fail "the retired-pane absorb should be logged to the triage log" +pass "handle_push_transition: a pane with no recorded task (torn down, or never one) is absorbed - never a wake" + +# The exact same edge on the exact same pane DOES still wake once that pane is +# a currently-recorded live task - proves the fix is scoped to unrecorded +# panes only, never weakening genuine detection for a live crew. +reset_state +fm_write_meta "$STATE_DIR/tk1b.meta" "window=default:wZ:pR" "backend=herdr" "kind=ship" +handle_push_transition herdr default "$(mkrec wZ:pR blocked)" +[ -s "$WAKE_LOG" ] || fail "the same window must still wake once it is a recorded live task" +grep -q 'default:wZ:pR' "$STATE_DIR/.wake-queue" 2>/dev/null || fail "the live task's stale record must still name its window" +pass "handle_push_transition: the identical pane still wakes once it is a recorded live task (fix is scoped to unrecorded panes)" + # --- handle_push_transition: absorb (no wake, no enqueue) for a declared pause - reset_state