Skip to content
50 changes: 50 additions & 0 deletions bin/fm-crew-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -322,6 +326,32 @@ pane_readable() { # <target>
*) 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 "<busy|idle|unknown> <source>". A converted
# adapter answers from its own lifecycle record; Grok answers from its
Expand Down Expand Up @@ -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)"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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
Expand Down
104 changes: 104 additions & 0 deletions tests/fm-crew-state.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading