diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 26e5b2d26cb..fcd9a751689 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -157,6 +157,10 @@ # unreachable, and an alive endpoint whose scrollback read failed is still # classified by step 4. Backends with no classifier keep reading a failed # capture as gone. The fallback's own comment owns the per-verdict rules. +# A readable endpoint holding only its shell (the harness exited, for +# example on a provider usage-limit error) reads unknown ยท none as agent +# gone before a stale busy record or non-terminal status log can report +# working; a valid terminal done/failed declaration is still preserved. # # 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). @@ -322,6 +326,32 @@ pane_readable() { # *) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;; esac } +# crew_agent_gone: positive recovery-grade evidence that a readable endpoint +# contains only its shell, not the recorded harness. Tmux first confirms its +# foreground command is a shell so a transient process-table read cannot turn a +# live agent into a dead one; Herdr owns that distinction in its own classifier. +# A dead agent must outrank stale busy or status-log evidence, but every +# ambiguous, unreadable, or unverified result preserves the existing fallback. +crew_agent_gone() { + local current state + fm_backend_source "$TASK_BACKEND" || return 1 + case "$TASK_BACKEND" in + tmux) + current=$(fm_backend_tmux_current_command "$BACKEND_TARGET" 2>/dev/null) || return 1 + case "$(fm_agent_process_classify_name "$current")" in + shell) ;; + *) return 1 ;; + esac + ;; + herdr) ;; + *) return 1 ;; + esac + state=$(fm_backend_agent_state "$TASK_BACKEND" "$BACKEND_TARGET") + case "$state" in + dead|missing) return 0 ;; + *) return 1 ;; + esac +} # crew_busy_verdict: the crew's semantic busy state from the one contract # owner (bin/fm-busy-lib.sh), as " ". A converted # adapter answers from its own lifecycle record; Grok answers from its @@ -1283,6 +1313,26 @@ if ! pane_readable "$BACKEND_TARGET"; then esac fi +# A readable shell-only endpoint is an agent-free worker, not an idle worker. +# Do this before accepting either a stale semantic busy record or the status log, +# while leaving secondmate liveness to its routed status contract below. A +# terminal declaration is the exception: once the harness is positively gone, +# preserve a valid ship/scout outcome instead of losing it to the death verdict. +if [ "$KIND" != secondmate ] && crew_agent_gone; then + case "$LOG_VERB" in + "done") + if [ "$KIND" = ship ]; then + emit_ship_status_done + fi + emit "done" status-log "$(status_line_note "$LOG_LINE")" + ;; + failed) + emit failed status-log "$(status_line_note "$LOG_LINE")" + ;; + esac + emit unknown none "backend target gone: $BACKEND_TARGET (agent gone, pane shell remains)" +fi + # 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/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index bf7d7bc5306..a0308568520 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -2755,6 +2755,108 @@ test_no_run_herdr_idle_agent_status_and_idle_record_stays_idle() { pass "an idle record with idle agent_status stays not-busy (no regression for a human-blocked agent)" } +# A provider-limit crash can leave the pane's shell, a stale Pi lifecycle +# record, and the last `working:` event together. The shell is readable, so +# the old fallback accepted the stale record/log as current and the watcher had +# no dead-agent evidence to surface. A live agent remains eligible for the +# ordinary status-log fallback; only the shell-only endpoint is overridden. +test_no_run_agent_free_pi_does_not_use_stale_state() { + reset_fakes + local d out gen + d=$(new_case agent-free-pi) + make_repo_on_branch "$d/wt" fm/feat-agent-free-pi + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-agent-free-pi.meta" \ + "window=fm:fm-agent-free-pi" "worktree=$d/wt" "kind=ship" \ + "backend=tmux" "harness=pi" + printf 'working: started before provider quota ended the harness\n' > \ + "$d/state/feat-agent-free-pi.status" + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-agent-free-pi) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-agent-free-pi idle --gen "$gen" \ + --source pi-ext --event stop + cat > "$d/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + list-windows) printf 'fm-agent-free-pi\n' ;; + display-message) + format= + for arg in "$@"; do format=$arg; done + case "$format" in + '#{pane_id}') printf '%%1\n' ;; + '#{pane_current_command}') printf 'zsh\n' ;; + '#{pane_tty}') printf '\n' ;; + *) printf '%%1\n' ;; + esac + ;; + capture-pane) printf '403 You have reached your 5-hour usage limit\n> \n' ;; +esac +SH + chmod +x "$d/fakebin/tmux" + out=$(run_crew_state "$d" feat-agent-free-pi) + assert_contains "$out" "state: unknown" \ + "an agent-free Pi pane must not remain working" + assert_contains "$out" "source: none" \ + "agent-free evidence must bypass stale status-log fallback" + assert_contains "$out" "agent gone, pane shell remains" \ + "the reconciliation detail must name the dead harness" + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-agent-free-pi busy --gen "$gen" \ + --source pi-ext --event user-prompt-submit + out=$(run_crew_state "$d" feat-agent-free-pi) + assert_contains "$out" "state: unknown" \ + "a stale Pi busy record must not survive the harness exit" + assert_contains "$out" "source: none" \ + "dead-agent evidence must outrank stale pane busy state" + pass "agent-free Pi quota pane does not trust stale busy or status evidence" +} + +# A harness can exit after recording a terminal declaration, leaving only its +# shell in the readable pane. Terminal outcomes remain current, while stale +# working evidence is still rejected by the agent-free check below. +test_no_run_terminal_status_survives_shell_only_endpoint() { + local kind verb d id out + for kind in ship scout; do + for verb in 'done' failed; do + reset_fakes + id="terminal-${kind}-${verb}" + d=$(new_case "$id") + make_repo_on_branch "$d/wt" "fm/$id" + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/$id.meta" \ + "window=fm:fm-$id" "worktree=$d/wt" "kind=$kind" \ + "mode=no-mistakes" "backend=tmux" "harness=pi" + printf '%s: harness exited after terminal outcome\n' "$verb" > \ + "$d/state/$id.status" + cat > "$d/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + list-windows) printf 'fm-terminal-%s-%s\n' "${FM_FAKE_TERMINAL_KIND:-ship}" "${FM_FAKE_TERMINAL_VERB:-done}" ;; + display-message) + format= + for arg in "$@"; do format=$arg; done + case "$format" in + '#{pane_id}') printf '%%1\n' ;; + '#{pane_current_command}') printf 'zsh\n' ;; + '#{pane_tty}') printf '\n' ;; + *) printf '%%1\n' ;; + esac + ;; + capture-pane) printf 'terminal outcome\n> \n' ;; +esac +SH + chmod +x "$d/fakebin/tmux" + out=$(FM_FAKE_TERMINAL_KIND="$kind" FM_FAKE_TERMINAL_VERB="$verb" \ + run_crew_state "$d" "$id") + assert_contains "$out" "state: $verb" \ + "$kind $verb survives a shell-only endpoint" + assert_contains "$out" "source: status-log" \ + "$kind $verb remains status-log sourced" + done + done + pass "terminal status survives a shell-only endpoint" +} + # (g) no run + idle pane -> the status-log verb, as-is test_no_run_idle_pane_uses_log() { reset_fakes @@ -5588,6 +5690,8 @@ test_no_run_herdr_alive_with_failed_read_stays_live test_no_run_herdr_husk_dead_still_reads_gone test_no_run_herdr_idle_agent_status_outranked_by_record test_no_run_herdr_idle_agent_status_and_idle_record_stays_idle +test_no_run_agent_free_pi_does_not_use_stale_state +test_no_run_terminal_status_survives_shell_only_endpoint test_no_run_idle_pane_uses_log test_no_run_idle_pane_uses_keyed_log test_no_run_idle_pane_paused