Skip to content
Merged
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
15 changes: 15 additions & 0 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,21 @@ fm_backend_agent_state() { # <backend> <target>
esac
}

# fm_backend_launch_confirmable: 0 when <backend>'s fm_backend_agent_state can
# prove a just-launched agent for every verified harness from the pane's own
# process table, which is what lets bin/fm-spawn.sh require that proof before
# reporting a launch. tmux reads the pane's foreground process group directly.
# Herdr's `alive` additionally needs Herdr's own registration of the agent,
# which is not established for every harness, so a harness Herdr never
# registers would read agent-free while it runs; zellij, orca, and cmux have no
# classifier at all.
fm_backend_launch_confirmable() { # <backend>
case "${1-}" in
tmux) return 0 ;;
esac
return 1
}

# Backward-compatible three-state view for existing callers. An
# authoritatively missing endpoint is confidently not a live agent, while every
# ambiguous, unreadable, or unverified result stays unknown.
Expand Down
97 changes: 94 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@
# the new incarnation. The replacement still never starts outside the copy
# holding the work: a Herdr shell that has drifted out of the recorded
# worktree is told once to return, and only a shell that will not go refuses.
# Before typing anything, a relaunch clears whatever the adopted shell still
# holds with Ctrl+C, so a previous launch that never landed - a partial line
# or an open quote - cannot swallow the replacement's.
# Launch confirmation: a typed launch is never its own proof that an agent
# started. A line longer than the terminal's canonical-input limit (1024
# bytes on macOS) that arrives before the pane shell's line editor is running
# is cut short, which can leave the shell at a continuation prompt with no
# agent at all. On a backend whose agent-state classifier proves a launched
# agent from the pane's own processes (bin/fm-backend.sh's
# fm_backend_launch_confirmable: tmux) every launch, fresh or relaunch,
# reports success only after fm_backend_agent_state reads the agent alive -
# for a raw launch command, whose process no classifier names, once anything
# but a shell holds the foreground - polling FM_SPAWN_LAUNCH_POLLS times
# (default 60) every FM_SPAWN_LAUNCH_POLL_INTERVAL seconds (default 0.5).
# An endpoint that still reads agent-free gets one in-place retry: Ctrl+C
# clears the shell's pending input and the launch is typed again. A second
# miss, or any other state, fails the spawn and appends a failed: status
# line. A fresh spawn then closes its endpoint and rolls back its record; a
# relaunch keeps its endpoint and record for bin/fm-control.sh to reconcile.
# Every other backend launches unconfirmed here; bin/fm-control.sh still
# confirms a Herdr relaunch.
# --harness <name> is the explicit per-spawn harness/profile adapter. The old
# positional harness arg still works for back-compat.
# --model <name> and --effort <low|medium|high|xhigh|max|ultra> are concrete profile
Expand Down Expand Up @@ -3452,7 +3473,7 @@ rovo_wait_for_delivery() {
rovo_spawn_fail() { # <detail>
printf 'failed: %s\n' "$1" >>"$STATE/$ID.status"
echo "error: $1; inspect window $T" >&2
rovo_endpoint_cleanup
spawn_launch_endpoint_cleanup
}

# The launch-then-confirm gates run after the task record is published, when
Expand All @@ -3462,7 +3483,7 @@ rovo_spawn_fail() { # <detail>
# task control. Mirrors fm-teardown.sh's own generic kill call. On orca only
# the exact terminal is closed: that stops the CLI while its worktree stays
# for the record's own teardown, which owns worktree deletion.
rovo_endpoint_cleanup() {
spawn_launch_endpoint_cleanup() {
if [ "$BACKEND" = orca ]; then
fm_backend_kill orca "$T" 2>/dev/null || true
return 0
Expand Down Expand Up @@ -3525,7 +3546,71 @@ agy_wait_for_working() {
agy_spawn_fail() { # <detail>
printf 'failed: %s\n' "$1" >> "$STATE/$ID.status"
echo "error: $1; inspect window $T" >&2
rovo_endpoint_cleanup
spawn_launch_endpoint_cleanup
}

# Launch confirmation and the pending-input clear; the header's "Launch
# confirmation" paragraph owns the contract.
spawn_clear_shell_input() {
# Ctrl+C discards a partial line or a whole continuation prompt in every
# supported shell. Called only on an endpoint proven agent-free, so the
# interrupt can only reach a shell sitting at its prompt.
spawn_send_key "$T" C-c || return 1
sleep 0.3
}

spawn_type_launch() {
spawn_send_literal "$T" "$LAUNCH"
sleep 0.3
spawn_send_key "$T" Enter
}

spawn_launch_started() { # prints the last endpoint state read
local state i=0 max=${FM_SPAWN_LAUNCH_POLLS:-60} interval=${FM_SPAWN_LAUNCH_POLL_INTERVAL:-0.5}
while [ "$i" -lt "$max" ]; do
state=$(fm_backend_agent_state "$BACKEND" "$T")
case "$state" in
alive) printf '%s' "$state"; return 0 ;;
ambiguous) [ "$RAW_LAUNCH" -eq 0 ] || { printf '%s' "$state"; return 0; } ;;
esac
i=$((i + 1))
[ "$i" -ge "$max" ] || sleep "$interval"
done
printf '%s' "$state"
return 1
}

SPAWN_LAUNCH_STATE=
spawn_confirm_launch() {
fm_backend_launch_confirmable "$BACKEND" || return 0
SPAWN_LAUNCH_STATE=$(spawn_launch_started) && return 0
[ "$SPAWN_LAUNCH_STATE" = dead ] || return 1
spawn_clear_shell_input || return 1
spawn_type_launch
SPAWN_LAUNCH_STATE=$(spawn_launch_started)
}

# A fresh endpoint holds nothing but this failed launch, so it is closed with
# the record the abort path rolls back. A relaunch endpoint is the task's own
# and is never closed; its caller reconciles the retained record.
spawn_launch_fail() { # <detail>
printf 'failed: %s\n' "$1" >> "$STATE/$ID.status"
echo "error: $1; inspect window $T" >&2
[ "$RELAUNCH" -eq 1 ] || spawn_launch_endpoint_cleanup
}

SPAWN_RELAUNCH_INPUT_CLEARED=0
# A relaunch adopts a shell proven agent-free above, but that shell may still
# hold a previous launch that never landed - a partial line or an open quote -
# that would swallow everything typed next. Clear it once, immediately before
# the first line is typed, so a relaunch refused earlier sends nothing at all.
spawn_relaunch_clear_input() {
[ "$RELAUNCH" -eq 1 ] && [ "$SPAWN_RELAUNCH_INPUT_CLEARED" -eq 0 ] || return 0
spawn_clear_shell_input || {
echo "error: task $ID's endpoint $T could not be cleared before relaunch; refusing to type into input that may still be pending" >&2
exit 1
}
SPAWN_RELAUNCH_INPUT_CLEARED=1
}

if [ "$RELAUNCH" -eq 1 ]; then
Expand All @@ -3546,6 +3631,7 @@ if [ "$RELAUNCH" -eq 1 ]; then
exit 1
fi
relaunch_cd_path=${WT//\'/\'\\\'\'}
spawn_relaunch_clear_input
spawn_send_text_line "$WT_TARGET" "cd -- '$relaunch_cd_path'" || {
echo "error: task $ID's endpoint is in '${relaunch_seen:-unknown}' and could not be told to return to its recorded worktree '$WT'; refusing to relaunch an agent outside the copy holding its work" >&2
exit 1
Expand Down Expand Up @@ -4442,6 +4528,7 @@ spawn_record_traceparent() {
# Export GOTMPDIR into the crewmate's pane shell so the agent and every child
# process (go build, go test, ...) inherit it. Sent before the launch command so
# the env is set when the agent starts; the brief sleep lets the export land.
spawn_relaunch_clear_input
spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp"
# Mark the pane as a task worker so bin/fm-test-run.sh can refuse to run the
# suite in the repository's primary checkout. Ship and scout workers are the
Expand Down Expand Up @@ -4496,6 +4583,10 @@ if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then
spawn_herdr_presentation_order_lock_release
fi
spawn_send_key "$T" Enter
if ! spawn_confirm_launch; then
spawn_launch_fail "no agent started in window $T after the launch command was typed (the endpoint reads '$SPAWN_LAUNCH_STATE')"
exit 1
fi
if [ "$HARNESS" = kimi ]; then
if ! kimi_wait_for_ready; then
kimi_spawn_fail "kimi did not show a verified ready signal before brief delivery"
Expand Down
1 change: 1 addition & 0 deletions docs/agent-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ It is not deterministic across the verified adapters: codex, grok, and gemini re
A secondmate relaunch does not require one and never rewrites its standing charter.
4. **Stop the old agent** through the `exit` verb, with its postcondition.
5. **Launch the replacement** through its single owner, `bin/fm-spawn.sh --relaunch`, which adopts the recorded endpoint and worktree instead of creating either, clears the previous harness's per-task wiring, and arms a fresh busy generation.
It clears any input the adopted shell still holds before typing, so a launch that never landed there cannot swallow the replacement's, and its header owns how it confirms the replacement actually started.

Switching harness is therefore one ordinary relaunch rather than a separate mechanism.

Expand Down
12 changes: 12 additions & 0 deletions tests/fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,32 @@ fm_test_fake_gh_axi() {
# The pane path defaults to empty when FM_FAKE_PANE_PATH is unset. Window
# cleanup and option operations are no-ops. Launch logging is env-gated, so
# suites that do not set FM_FAKE_LAUNCH_LOG keep a silent send-keys.
#
# A launched agent is modelled for fm-spawn.sh's launch confirmation: the
# window inventory lists fm-<id> for every task record in the home's state
# directory (so the pre-create duplicate check still sees no window for the
# task being spawned), and the pane's foreground command is
# FM_FAKE_PANE_COMMAND, default claude, which the tmux classifier reads alive.
fm_test_fake_tmux_spawn() {
local fakebin=$1
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
set -u
case "$*" in
*"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;;
*"#{pane_current_command}"*) printf '%s\n' "${FM_FAKE_PANE_COMMAND:-claude}"; exit 0 ;;
esac
case "${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows)
if [ -n "${FM_FAKE_DUPLICATE_WINDOW:-}" ]; then
printf '%s\n' "$FM_FAKE_DUPLICATE_WINDOW"
fi
for meta in "${FM_STATE_OVERRIDE:-${FM_HOME:-/nonexistent}/state}"/*.meta; do
[ -e "$meta" ] || continue
meta=${meta##*/}
printf 'fm-%s\n' "${meta%.meta}"
done
exit 0
;;
has-session|new-session|new-window|kill-window|set-window-option) exit 0 ;;
Expand Down
10 changes: 9 additions & 1 deletion tests/fm-agy-harness.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,18 @@ fake_path_trusted() {
case "$*" in
*"#{pane_current_path}"*) printf '%s\n' "$FM_FAKE_PANE_PATH"; exit 0 ;;
*"#{cursor_y}"*) printf '1\n'; exit 0 ;;
*"#{pane_current_command}"*) printf 'agy\n'; exit 0 ;;
esac
case "${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows) exit 0 ;;
list-windows)
for meta in "$FM_STATE_OVERRIDE"/*.meta; do
[ -e "$meta" ] || continue
meta=${meta##*/}
printf 'fm-%s\n' "${meta%.meta}"
done
exit 0
;;
has-session|new-session|new-window|kill-window) exit 0 ;;
send-keys)
literal=
Expand Down
18 changes: 16 additions & 2 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -800,9 +800,16 @@ set -u
{ printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}"
case "\${1:-}" in
display-message)
for a in "\$@"; do case "\$a" in *pane_current_command*) printf 'claude\\n'; exit 0 ;; esac; done
for a in "\$@"; do case "\$a" in *pane_current_path*) printf '%s\\n' "$wt"; exit 0 ;; esac; done
printf 'firstmate\\n'; exit 0 ;;
list-windows) exit 0 ;;
list-windows)
for meta in "\${FM_STATE_OVERRIDE:?}"/*.meta; do
[ -e "\$meta" ] || continue
meta=\${meta##*/}
printf 'fm-%s\\n' "\${meta%.meta}"
done
exit 0 ;;
esac
exit 0
SH
Expand Down Expand Up @@ -872,8 +879,15 @@ case "\${1:-}" in
fi
exit 0
;; esac; done
for a in "\$@"; do case "\$a" in *pane_current_command*) printf 'claude\\n'; exit 0 ;; esac; done
printf 'firstmate\\n'; exit 0 ;;
list-windows) exit 0 ;;
list-windows)
for meta in "\${FM_STATE_OVERRIDE:?}"/*.meta; do
[ -e "\$meta" ] || continue
meta=\${meta##*/}
printf 'fm-%s\\n' "\${meta%.meta}"
done
exit 0 ;;
esac
exit 0
SH
Expand Down
18 changes: 16 additions & 2 deletions tests/fm-backlog-atomicity.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,24 @@ Delivery contract: mode=no-mistakes
EOF
done

# A launched agent reads alive to fm-spawn.sh's launch confirmation: each
# recorded task has its fm-<id> window, whose foreground command is claude.
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac
case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; esac
case "$*" in
*"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;;
*"#{pane_current_command}"*) printf 'claude\n'; exit 0 ;;
esac
case "${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows)
for meta in "$FM_HOME"/state/*.meta; do
[ -e "$meta" ] || continue
meta=${meta##*/}
printf 'fm-%s\n' "${meta%.meta}"
done
;;
esac
exit 0
SH
chmod +x "$fakebin/tmux"
Expand Down
10 changes: 9 additions & 1 deletion tests/fm-kimi-harness.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,19 @@ fake_cursor_y() {
}
case "$*" in
*"#{pane_current_path}"*) printf '%s\n' "$FM_FAKE_PANE_PATH"; exit 0 ;;
*"#{pane_current_command}"*) printf 'kimi\n'; exit 0 ;;
*"#{cursor_y}"*) fake_cursor_y; exit 0 ;;
esac
case "${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows) exit 0 ;;
list-windows)
for meta in "${FM_STATE_OVERRIDE:-${FM_HOME:-/nonexistent}/state}"/*.meta; do
[ -e "$meta" ] || continue
meta=${meta##*/}
printf 'fm-%s\n' "${meta%.meta}"
done
exit 0
;;
has-session|new-session|new-window|kill-window) exit 0 ;;
send-keys)
prev=
Expand Down
10 changes: 9 additions & 1 deletion tests/fm-muse-harness.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ make_spawn_fakebin() {
set -u
case "$*" in
*"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;;
*"#{pane_current_command}"*) printf 'muse\n'; exit 0 ;;
esac
case "${1:-}" in
show-environment)
Expand All @@ -82,7 +83,14 @@ case "${1:-}" in
exit 0
;;
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows) exit 0 ;;
list-windows)
for meta in "${FM_STATE_OVERRIDE:-${FM_HOME:-/nonexistent}/state}"/*.meta; do
[ -e "$meta" ] || continue
meta=${meta##*/}
printf 'fm-%s\n' "${meta%.meta}"
done
exit 0
;;
has-session|new-session|new-window|kill-window) exit 0 ;;
send-keys)
prev=
Expand Down
10 changes: 9 additions & 1 deletion tests/fm-rovo-harness.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,19 @@ fake_cursor_y() {
}
case "$*" in
*"#{pane_current_path}"*) printf '%s\n' "$FM_FAKE_PANE_PATH"; exit 0 ;;
*"#{pane_current_command}"*) printf 'rovo\n'; exit 0 ;;
*"#{cursor_y}"*) fake_cursor_y; exit 0 ;;
esac
case "${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows) exit 0 ;;
list-windows)
for meta in "${FM_STATE_OVERRIDE:-${FM_HOME:-/nonexistent}/state}"/*.meta; do
[ -e "$meta" ] || continue
meta=${meta##*/}
printf 'fm-%s\n' "${meta%.meta}"
done
exit 0
;;
has-session|new-session|new-window|kill-window) exit 0 ;;
send-keys)
prev=
Expand Down
Loading
Loading