Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bin/backends/cmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,14 @@ fm_backend_cmux_surface_exists() { # <workspace_id> <surface_id>
# 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() { # <target> [expected-label]
fm_backend_cmux_target_ready "$@"
}

fm_backend_cmux_target_ready() { # <target> [expected-label]
local expected_label=${2:-} expected_title title wsid sfid
fm_backend_cmux_parse_target "$1" || return 1
Expand Down
23 changes: 23 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2507,6 +2507,29 @@ fm_backend_herdr_parse_target() { # <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() { # <target> [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() { # <target>
fm_backend_herdr_parse_target "$1" || return 1
fm_backend_herdr_server_ensure "$FM_BACKEND_HERDR_SESSION" || return 1
Expand Down
7 changes: 7 additions & 0 deletions bin/backends/orca.sh
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ fm_backend_orca_capture() { # <terminal-id> <lines>
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() { # <target> [expected-label]
fm_backend_orca_capture "$1" 1 >/dev/null 2>&1
}

fm_backend_orca_json_text() { # <json>
printf '%s' "$1" | node -e '
const fs = require("fs");
Expand Down
35 changes: 35 additions & 0 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,38 @@ fm_backend_tmux_agent_alive() { # <target>
*) 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() { # <target> [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
}
8 changes: 8 additions & 0 deletions bin/backends/zellij.sh
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,14 @@ fm_backend_zellij_parse_target() { # <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() { # <target> [expected-label]
fm_backend_zellij_target_ready "$@"
}

fm_backend_zellij_target_ready() { # <target> [expected-label]
local expected_label=${2:-} tab_id
fm_backend_zellij_parse_target "$1" || return 1
Expand Down
64 changes: 20 additions & 44 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -820,53 +820,29 @@ fm_backend_composer_state() { # <backend> <target> [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_<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() { # <backend> <target> [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
}

Expand Down
25 changes: 19 additions & 6 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id> 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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
1 change: 1 addition & 0 deletions docs/remote-secondmates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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.
Expand Down
3 changes: 3 additions & 0 deletions docs/tmux-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ Verify setup by spawning a small task and confirming its `fm-<id>` 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.
Expand Down
34 changes: 34 additions & 0 deletions tests/fm-backend-tmux-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions tests/fm-send-strict.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand Down
Loading