diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 0d9791216a3..3b8e888c2b7 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -407,6 +407,14 @@ fm_backend_cmux_surface_exists() { # # fm_backend_cmux_surface_exists (never read-screen - see that function's # header for the fresh-surface pitfall this avoids). When the caller knows # the owning firstmate task label, refresh stale workspace/surface ids by label. +# fm_backend_cmux_target_exists: the cmux half of fm_backend_target_exists's +# cheap READ-ONLY presence probe. target_ready already verifies surface +# existence without starting anything (never read-screen), so this thin alias +# keeps the dispatcher uniform. +fm_backend_cmux_target_exists() { # [expected-label] + fm_backend_cmux_target_ready "$@" +} + fm_backend_cmux_target_ready() { # [expected-label] local expected_label=${2:-} expected_title title wsid sfid fm_backend_cmux_parse_target "$1" || return 1 diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index c5f270bdaf9..b94ea4e59d3 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2507,6 +2507,29 @@ fm_backend_herdr_parse_target() { # [ -n "$FM_BACKEND_HERDR_SESSION" ] && [ -n "$FM_BACKEND_HERDR_PANE" ] && [ "$FM_BACKEND_HERDR_PANE" != "$target" ] } +# fm_backend_herdr_target_exists: the herdr half of fm_backend_target_exists's +# cheap READ-ONLY presence probe. Deliberately queries the pane DIRECTLY instead +# of going through fm_backend_herdr_target_ready, which auto-starts the herdr +# server as a side effect via fm_backend_herdr_server_ensure - fine for an +# operation about to use the pane, wrong for a passive liveness probe. An +# unqueryable pane (server down, pane closed) simply fails, which IS "does not +# exist" for this purpose. +fm_backend_herdr_target_exists() { # [expected-label] + local target=$1 session pane + session=${target%%:*} + pane=${target#*:} + [ -n "$session" ] && [ -n "$pane" ] && [ "$pane" != "$target" ] || return 1 + # fm_backend_herdr_cli (not a raw HERDR_SESSION-only call): verified + # empirically (docs/herdr-backend.md "Session targeting") that the bare + # env var alone is NOT reliably honored once another herdr server is + # already bound on the machine - it silently queries whatever server IS + # running instead. fm_backend_herdr_cli appends the required --session + # flag on top, so this check is correctly scoped even when the caller's + # own ambient session (e.g. the primary firstmate's default session) is + # a DIFFERENT one than the target's. + fm_backend_herdr_cli "$session" pane get "$pane" >/dev/null 2>&1 +} + fm_backend_herdr_target_ready() { # fm_backend_herdr_parse_target "$1" || return 1 fm_backend_herdr_server_ensure "$FM_BACKEND_HERDR_SESSION" || return 1 diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index 422a732313b..d5ee51514db 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -203,6 +203,13 @@ fm_backend_orca_capture() { # fm_backend_orca_json_text "$out" } +# fm_backend_orca_target_exists: the Orca half of fm_backend_target_exists's +# cheap READ-ONLY presence probe. A one-line capture of the recorded terminal: +# an unreadable terminal fails, which IS "does not exist" for this purpose. +fm_backend_orca_target_exists() { # [expected-label] + fm_backend_orca_capture "$1" 1 >/dev/null 2>&1 +} + fm_backend_orca_json_text() { # printf '%s' "$1" | node -e ' const fs = require("fs"); diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 9eed5f3ec3e..640a4f54157 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -355,3 +355,38 @@ fm_backend_tmux_agent_alive() { # *) printf 'unknown' ;; esac } + +# fm_backend_tmux_target_exists: the tmux half of fm_backend_target_exists's +# cheap READ-ONLY presence probe. Replaces the old raw `tmux display-message -p +# -t "$target" '#{pane_id}'` call, which answered from the client's ACTIVE +# window whenever any tmux server was running - so it reported an absent window +# or session as present (the exact hazard fm_backend_tmux_current_command and +# the agent_state classifier above document and guard against). list-panes is +# authoritative instead: it exits non-zero when the target resolves to no pane, +# and never starts a server (a down server simply fails, which IS "does not +# exist" for a passive liveness probe). +# +# Two target shapes reach here. A bare pane or window id (`%N` or `@N`, e.g. the +# away-mode daemon's supervisor pane) is queried directly. A `session:window` +# endpoint is matched EXACTLY by anchoring both atoms with `=`, so +# `firstmate:fm-task` cannot match a `fm-task-2` prefix. A window atom that is +# itself an `@N`/`%N` id takes no `=` prefix, since exact-match anchoring is for +# names, not ids. The optional expected-label argument is accepted for a uniform +# signature but unused: tmux presence is settled by the target alone. +fm_backend_tmux_target_exists() { # [expected-label] + local target=$1 session window resolved + case "$target" in + *:*) + session=${target%%:*} + window=${target#*:} + case "$window" in + @*|%*) resolved="=$session:$window" ;; + *) resolved="=$session:=$window" ;; + esac + ;; + *) + resolved=$target + ;; + esac + tmux list-panes -t "$resolved" >/dev/null 2>&1 +} diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 56478f7db35..d04afa9b037 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -373,6 +373,14 @@ fm_backend_zellij_parse_target() { # # fm_backend_zellij_target_ready: parse the target and verify its session and # pane are alive. When the caller knows the owning firstmate task label, verify # the pane belongs to that named tab before trusting the numeric pane id. +# fm_backend_zellij_target_exists: the zellij half of fm_backend_target_exists's +# cheap READ-ONLY presence probe. Zellij never auto-starts a session on a plain +# readiness read, so target_ready already IS the passive existence check; this +# thin alias keeps the dispatcher uniform. +fm_backend_zellij_target_exists() { # [expected-label] + fm_backend_zellij_target_ready "$@" +} + fm_backend_zellij_target_ready() { # [expected-label] local expected_label=${2:-} tab_id fm_backend_zellij_parse_target "$1" || return 1 diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 2882f4a6af2..648e2cb4918 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -820,53 +820,29 @@ fm_backend_composer_state() { # [expected-label] -> empty|pe } # fm_backend_target_exists: cheap, READ-ONLY existence check - does the -# recorded TARGET endpoint still exist on BACKEND? Never starts a server or -# session: for herdr this deliberately queries the pane directly instead of -# going through fm_backend_herdr_target_ready (which auto-starts the herdr -# server as a side effect via fm_backend_herdr_server_ensure - fine for an -# operation that is about to use the pane, wrong for a passive liveness -# probe). A gone tmux window or an unqueryable herdr pane (server down, pane -# closed), missing zellij pane, or unreadable Orca terminal simply fails, which -# IS "does not exist" for this purpose. -# Mirrors fm-crew-state.sh's pane_readable check; exists here as one shared -# primitive so callers that only need a fast alive/dead read (recovery +# recorded TARGET endpoint still exist on BACKEND? A pure per-backend +# dispatcher, exactly like its fm_backend_composer_state / fm_backend_agent_state +# siblings: each adapter's own fm_backend__target_exists owns the exact +# probe and its never-start-a-server contract, so no presence answer is derived +# inline here. A gone tmux window or session, an unqueryable herdr pane (server +# down, pane closed), a missing zellij/cmux pane, or an unreadable Orca terminal +# simply fails, which IS "does not exist" for this purpose. +# Serves the same fast alive/dead role as fm-crew-state.sh's pane_readable +# fallback, without sharing its probe (that fallback is only consulted when no +# run-step is available, so it stayed on its own tmux read); exists here as one +# shared primitive so callers that only need a fast alive/dead read (recovery # digests, the session-start fleet digest) do not re-derive it inline. fm_backend_target_exists() { # [expected-label] - local backend=$1 target=$2 expected_label=${3:-} session pane + local backend=$1 + shift + fm_backend_source "$backend" || return 1 case "$backend" in - tmux) - tmux display-message -p -t "$target" '#{pane_id}' >/dev/null 2>&1 - ;; - herdr) - fm_backend_source herdr || return 1 - session=${target%%:*} - pane=${target#*:} - [ -n "$session" ] && [ -n "$pane" ] && [ "$pane" != "$target" ] || return 1 - # fm_backend_herdr_cli (not a raw HERDR_SESSION-only call): verified - # empirically (docs/herdr-backend.md "Session targeting") that the bare - # env var alone is NOT reliably honored once another herdr server is - # already bound on the machine - it silently queries whatever server IS - # running instead. fm_backend_herdr_cli appends the required --session - # flag on top, so this check is correctly scoped even when the caller's - # own ambient session (e.g. the primary firstmate's default session) is - # a DIFFERENT one than the target's. - fm_backend_herdr_cli "$session" pane get "$pane" >/dev/null 2>&1 - ;; - zellij) - fm_backend_source zellij || return 1 - fm_backend_zellij_target_ready "$target" "$expected_label" - ;; - orca) - fm_backend_source orca || return 1 - fm_backend_orca_capture "$target" 1 >/dev/null 2>&1 - ;; - cmux) - fm_backend_source cmux || return 1 - fm_backend_cmux_target_ready "$target" "$expected_label" - ;; - *) - return 1 - ;; + tmux) fm_backend_tmux_target_exists "$@" ;; + herdr) fm_backend_herdr_target_exists "$@" ;; + zellij) fm_backend_zellij_target_exists "$@" ;; + orca) fm_backend_orca_target_exists "$@" ;; + cmux) fm_backend_cmux_target_exists "$@" ;; + *) return 1 ;; esac } diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index a0383e46810..1039936f82d 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -45,7 +45,9 @@ # represented by the two digests below. # 6. fleet digest - a compact data/backlog.md identity/metadata listing, # every state/*.meta, a bounded state/*.status tail, -# state/.afk, and a cheap per-task endpoint-liveness read: +# state/.afk, and a cheap per-task endpoint-liveness read +# (a remote: endpoint lives on another host, so it is +# reported as not locally probed instead of alive/dead): # read-only, always runs. # 7. network checks - the result of the deferred network stage started back at # step 1, harvested WITHOUT waiting for it. @@ -800,11 +802,22 @@ for meta in "$STATE"/*.meta; do target=$(fm_backend_target_of_meta "$meta") if [ -n "$window" ]; then backend=$(fm_backend_of_meta "$meta") - if fm_backend_target_exists "$backend" "${target:-$window}" "fm-$id"; then - printf 'endpoint: alive (backend=%s window=%s)\n' "$backend" "$window" - else - printf 'endpoint: dead (backend=%s window=%s)\n' "$backend" "$window" - fi + # A remote secondmate's agent runs on another host, so no local backend + # probe could ever resolve its window; report that rather than reading a + # live remote endpoint as dead. docs/remote-secondmates.md owns how remote + # endpoints are actually read. + case "$window" in + remote:*) + printf 'endpoint: remote (not locally probed; window=%s)\n' "$window" + ;; + *) + if fm_backend_target_exists "$backend" "${target:-$window}" "fm-$id"; then + printf 'endpoint: alive (backend=%s window=%s)\n' "$backend" "$window" + else + printf 'endpoint: dead (backend=%s window=%s)\n' "$backend" "$window" + fi + ;; + esac else printf 'endpoint: unknown (no window recorded)\n' fi diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 86bad52b44c..8f72a54a739 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -1443,9 +1443,10 @@ fm_super_main() { # --- validate supervisor target at startup (a missing target is a typo) --- # Dispatches through bin/fm-backend.sh instead of a raw `tmux display-message` - # probe, so a herdr supervisor pane is checked via the herdr adapter; for - # backend=tmux this runs the exact same `tmux display-message -p -t "$TARGET" - # '#{pane_id}'` call as before. + # probe, so a herdr supervisor pane is checked via the herdr adapter, and a + # tmux one is answered by that adapter's exact-membership `tmux list-panes` + # probe rather than by the old `display-message` call, which reported the + # client's active window as present for an absent `%N` target. if ! fm_backend_target_exists "$BACKEND" "$TARGET"; then echo "error: supervisor target '$TARGET' does not resolve to a $BACKEND pane; set FM_SUPERVISOR_TARGET" >&2 log "startup failed: target '$TARGET' not found (backend=$BACKEND)" diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index b5ac0f9db3b..6c425b29ab2 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -172,6 +172,7 @@ The [`fm-send.sh` header](../bin/fm-send.sh) owns the exact delivery-status cont When the verified remote endpoint accepts the text and Enter but synchronous submit confirmation remains pending, the primary reports the request as delivered rather than failed; do not resend it, because its pending-reply expectation remains armed. `fm-peek.sh` and `fm-crew-state.sh` route remote-secondmate reads to the endpoint's host instead of consulting local worktree or backend state. An unreachable or unreadable remote read is unknown, not evidence that the endpoint is dead. +For the same reason, the session-start fleet digest never probes a `remote:` endpoint locally: it reports that endpoint as not locally probed instead of classifying it alive or dead. Marked requests keep the existing correlation contract. The remote charter appends replies to `state/parent-replies.status` in the remote home. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 64baae44a77..46a2815b974 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -47,6 +47,9 @@ Verify setup by spawning a small task and confirming its `fm-` window appear ### Agent liveness probe A target-existence check proves only that the pane exists. +It answers from exact pane membership (`tmux list-panes`), never from the attached client's active window, so an absent window, an absent session, or an absent `%N`/`@N` id reads as gone even while a tmux server is running. +Both atoms of a `session:window` endpoint are anchored to an exact name match, so `firstmate:fm-task` never matches a longer `fm-task-2` window. +The check never starts a tmux server: a down server simply fails, which is the correct answer for a passive liveness probe. The deeper tmux agent-liveness probe first verifies exact window membership, then reads process names to distinguish a running harness from a bare idle shell. It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, Cursor, and Muse process identities as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. Only `dead` and `missing` authorize recovery because a false dead result could launch a duplicate agent. diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index aa1e07c3269..2e09a516ff0 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -156,6 +156,40 @@ if fm_backend_tmux_resolve_bare_selector "no-such-window-xyz" 2>/dev/null; then fi pass "real tmux: fm_backend_tmux_resolve_bare_selector fails for a window that does not exist" +# --- target_exists presence probe (list-panes, exact-match) ----------------- +# The old raw `tmux display-message -p -t "$target"` probe answered from the +# client's ACTIVE window whenever any server was running, so it reported an +# absent window or session as present. list-panes with `=sess:=win` anchoring +# is authoritative: it exists ONLY for the exact target. + +fm_backend_tmux_target_exists "$TARGET" \ + || fail "target_exists should find the live window '$TARGET'" +fm_backend_target_exists tmux "$TARGET" \ + || fail "the dispatcher should find the live window '$TARGET'" +fm_backend_tmux_target_exists "$SESSION:definitely-absent-xyz" \ + && fail "target_exists must fail for an absent window in a live session (not fall back to the active window)" +fm_backend_tmux_target_exists "no-such-session-xyz:$WINDOW" \ + && fail "target_exists must fail for an absent session" + +# Exact-match, not prefix: a window named "fm-task" must never be satisfied by a +# live "fm-task-2". +tmux new-window -t "$SESSION" -n fm-task-2 \ + || fail "could not create the prefix-collision window fm-task-2" +fm_backend_tmux_target_exists "$SESSION:fm-task-2" \ + || fail "target_exists should find the live window 'fm-task-2'" +fm_backend_tmux_target_exists "$SESSION:fm-task" \ + && fail "target_exists must NOT match the prefix 'fm-task' against a live 'fm-task-2'" +tmux kill-window -t "=$SESSION:=fm-task-2" 2>/dev/null || true + +# Bare pane-id (%N) targets, as the away-mode supervisor pane uses. +pane_id=$(tmux list-panes -t "=$SESSION:=$WINDOW" -F '#{pane_id}' 2>/dev/null | head -n1) +[ -n "$pane_id" ] || fail "could not read the live pane id for the %N probe" +fm_backend_tmux_target_exists "$pane_id" \ + || fail "target_exists should find the live pane by its %N id '$pane_id'" +fm_backend_tmux_target_exists "%999999" \ + && fail "target_exists must fail for an absent %N pane id" +pass "real tmux: target_exists finds live window/pane targets and fails for absent windows, sessions, prefixes, and %N ids" + # --- kill and recovery-grade missing-window classification ------------------ fm_backend_tmux_kill "$TARGET" diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh index 594d5421737..9389eebe9e4 100755 --- a/tests/fm-send-strict.test.sh +++ b/tests/fm-send-strict.test.sh @@ -59,6 +59,22 @@ case "${1:-}" in capture-pane) printf '╭────╮\n│ │\n╰────╯\n' exit 0 ;; + list-panes) + # target_exists's presence probe (was a display-message pane read); honor + # the same FM_FAKE_TMUX_DEAD_TARGET knob, stripping the probe's `=` + # exact-match anchors before comparing, so a dead target fails the check. + target= + prev= + for a in "$@"; do + [ "$prev" = "-t" ] && target=$a + prev=$a + done + target="${target//=/}" + if [ -n "${FM_FAKE_TMUX_DEAD_TARGET:-}" ] && [ "$target" = "$FM_FAKE_TMUX_DEAD_TARGET" ]; then + exit 1 + fi + printf '%%1\n' + exit 0 ;; list-windows) printf 'foreign:%s\n' "${FM_FAKE_TMUX_WINDOW:-fm-lost}" exit 0 ;; diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 9f1cedbc6ec..2524c0cb2ad 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -290,22 +290,27 @@ SH chmod +x "$fakebin/ps" } -# make_fake_tmux : display-message succeeds only for -# the given "session:window" target - the exact primitive -# fm_backend_target_exists uses for a tmux endpoint liveness read. +# make_fake_tmux : list-panes succeeds only for the given +# "session:window" target - the exact primitive fm_backend_target_exists now +# uses for a tmux endpoint liveness read. tmux's `=sess:=win` exact-match +# anchoring is modeled by stripping the `=` atoms before comparing, so an absent +# window or session correctly fails (the old raw display-message probe this +# replaces answered from the client's active window instead, reporting an absent +# endpoint as present). make_fake_tmux() { local fakebin=$1 live=$2 cat > "$fakebin/tmux" < "$home/state/sm-far.meta" + + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + assert_contains "$out" "endpoint: remote (not locally probed; window=remote:sm-far)" "remote endpoint not reported as remote" + case "$out" in + *"endpoint: dead (backend=tmux window=remote:sm-far)"*) + fail "remote endpoint was probed locally and reported dead" ;; + esac + + pass "a remote secondmate endpoint is reported as remote, never probed as a local target" +} + # --- composition: real scripts run, not reimplemented ------------------------ test_composition_invokes_real_scripts() { @@ -2420,6 +2462,7 @@ test_status_tail_line_cap test_orphan_status_logs_are_printed test_endpoint_liveness_tmux test_endpoint_liveness_herdr +test_endpoint_liveness_remote_not_probed test_composition_invokes_real_scripts test_backlog_compact_tasks_axi_omits_bodies_and_keeps_metadata test_backlog_queued_bound_discloses_its_remainder diff --git a/tests/wake-helpers.sh b/tests/wake-helpers.sh index 8e6281a5763..bec89cf2fae 100644 --- a/tests/wake-helpers.sh +++ b/tests/wake-helpers.sh @@ -157,6 +157,12 @@ case "${1:-}" in done [ "$_print" = 1 ] && printf 'fakepane\n' exit 0 ;; + list-panes) + # target_exists's presence probe (was a display-message pane read); gated by + # the same FM_FAKE_TMUX_PANE_ALIVE flag so a "dead" pane fails the check. + [ "${FM_FAKE_TMUX_PANE_ALIVE:-1}" = "1" ] || exit 1 + printf 'fakepane\n' + exit 0 ;; list-windows) [ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "$FM_FAKE_TMUX_WINDOW" exit 0 ;; @@ -245,6 +251,7 @@ case "${1:-}" in for a in "$@"; do [ "$a" = "-p" ] && print=1; done [ "$print" = 1 ] && printf 'fakepane\n' exit 0 ;; + list-panes) printf 'fakepane\n'; exit 0 ;; capture-pane) cat "$COMPOSER" 2>/dev/null; exit 0 ;; list-windows) exit 0 ;; send-keys)