diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 5a4e8378..2aed666c 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -930,6 +930,21 @@ fm_backend_agent_state() { # esac } +# fm_backend_launch_confirmable: 0 when '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() { # + 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. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index fbde92c7..c1dea20d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -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 is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. # --model and --effort are concrete profile @@ -3452,7 +3473,7 @@ rovo_wait_for_delivery() { rovo_spawn_fail() { # 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 @@ -3462,7 +3483,7 @@ rovo_spawn_fail() { # # 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 @@ -3525,7 +3546,71 @@ agy_wait_for_working() { agy_spawn_fail() { # 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() { # + 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 @@ -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 @@ -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 @@ -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" diff --git a/docs/agent-control.md b/docs/agent-control.md index 8dd03898..417df8e0 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -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. diff --git a/tests/fixtures.sh b/tests/fixtures.sh index b5eb48d9..ceccb84e 100755 --- a/tests/fixtures.sh +++ b/tests/fixtures.sh @@ -100,6 +100,12 @@ 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- 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' @@ -107,6 +113,7 @@ fm_test_fake_tmux_spawn() { 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 ;; @@ -114,6 +121,11 @@ case "${1:-}" in 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 ;; diff --git a/tests/fm-agy-harness.test.sh b/tests/fm-agy-harness.test.sh index d13f23c6..fcab421e 100755 --- a/tests/fm-agy-harness.test.sh +++ b/tests/fm-agy-harness.test.sh @@ -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= diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index b488b88b..f11f3be8 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -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 @@ -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 diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index a97a0d3d..344ee1fb 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -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- 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" diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 518a614b..1da4209a 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -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= diff --git a/tests/fm-muse-harness.test.sh b/tests/fm-muse-harness.test.sh index c8781cf1..06f10212 100755 --- a/tests/fm-muse-harness.test.sh +++ b/tests/fm-muse-harness.test.sh @@ -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) @@ -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= diff --git a/tests/fm-rovo-harness.test.sh b/tests/fm-rovo-harness.test.sh index 021d6ff0..2e08f839 100644 --- a/tests/fm-rovo-harness.test.sh +++ b/tests/fm-rovo-harness.test.sh @@ -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= diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 8645199c..4a54fd3a 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -435,13 +435,25 @@ test_propagate_lib() { # propagates the crew harness into the home's config. # =========================================================================== -# A tmux stub that accepts every subcommand and prints nothing, so no window -# pre-exists and the spawn proceeds to write its meta. Echoes the fakebin dir. +# A tmux stub that accepts every subcommand and prints nothing before a task is +# recorded, so no window pre-exists and the spawn proceeds to write its meta. +# Once the record exists its fm- window is listed with a claude foreground, +# which is what fm-spawn.sh's launch confirmation reads. Echoes the fakebin dir. make_noop_tmux() { local dir=$1 fakebin="$1/fakebin" mkdir -p "$fakebin" cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash +case "$*" in + 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 + ;; + *'#{pane_current_command}'*) printf 'claude\n' ;; +esac exit 0 SH chmod +x "$fakebin/tmux" @@ -662,10 +674,18 @@ make_launch_capturing_tmux() { set -u 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) 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) if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index a05a4196..3d6ddc57 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -269,7 +269,9 @@ SH # make_liveness_tmux : a controllable tmux stub. FM_TEST_PANE_CMD may be # a foreground command, `missing` (readable inventory omits the window), or -# `unreadable` (both pane and inventory reads fail). +# `unreadable` (both pane and inventory reads fail). A window the sweep creates +# holds the launched agent, which is what fm-spawn.sh's launch confirmation +# reads before a respawn reports success. make_liveness_tmux() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") @@ -277,6 +279,7 @@ make_liveness_tmux() { #!/usr/bin/env bash set -u mode=${FM_TEST_PANE_CMD:-zsh} +[ ! -e "${FM_TMUX_CALL_LOG:?}.launched" ] || mode=claude case "${1:-}" in display-message) for a in "$@"; do @@ -303,7 +306,10 @@ case "${1:-}" in printf '%s\n' "$*" >> "${FM_TMUX_CALL_LOG:?}" [ "${1:-}" = kill-window ] && : > "${FM_TMUX_CALL_LOG}.killed" [ "${FM_TEST_FAIL_NEW_WINDOW:-0}" = 1 ] && [ "${1:-}" = new-window ] && exit 1 - [ "${1:-}" = new-window ] && rm -f "${FM_TMUX_CALL_LOG}.killed" + if [ "${1:-}" = new-window ]; then + rm -f "${FM_TMUX_CALL_LOG}.killed" + : > "${FM_TMUX_CALL_LOG}.launched" + fi exit 0 ;; has-session) exit 0 ;; diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 0ba8ca6f..d409ebc2 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -775,11 +775,22 @@ test_spawn_fast_forwards_before_launch() { c2=$(head_of "$w/main") [ "$(head_of "$w/sm")" = "$c1" ] || fail "precondition: home should start behind the primary" - # tmux stub: accept every subcommand, print nothing (so no window pre-exists). + # tmux stub: accept every subcommand, print nothing until the task is + # recorded (so no window pre-exists), then report its launched agent. fakebin="$w/fakebin" mkdir -p "$fakebin" cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash +case "$*" in + 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 + ;; + *'#{pane_current_command}'*) printf 'codex\n' ;; +esac exit 0 SH chmod +x "$fakebin/tmux" @@ -814,6 +825,16 @@ test_spawn_warns_when_sync_skipped_before_launch() { mkdir -p "$fakebin" cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash +case "$*" in + 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 + ;; + *'#{pane_current_command}'*) printf 'codex\n' ;; +esac exit 0 SH chmod +x "$fakebin/tmux" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index b3d6aba1..bea219f0 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -355,8 +355,9 @@ case "${1:-}" in exit 0 fi if [ -e "$spawned" ]; then + # The respawned secondmate reads as a running Pi agent, as a real Pi pane does. case "$format" in - *pane_current_command*) printf '%s\n' node ;; + *pane_current_command*) printf '%s\n' pi-launcher ;; *) printf '%%1\n' ;; esac exit 0 diff --git a/tests/fm-shared-captain-inheritance.test.sh b/tests/fm-shared-captain-inheritance.test.sh index 6644c574..2f596764 100755 --- a/tests/fm-shared-captain-inheritance.test.sh +++ b/tests/fm-shared-captain-inheritance.test.sh @@ -214,8 +214,20 @@ make_fake_spawn_toolchain() { local dir=$1 fakebin fakebin="$dir/fakebin" mkdir -p "$fakebin" + # Every recorded task's window runs claude, so fm-spawn.sh's launch + # confirmation reads the launched agent alive. cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash +case "$*" in + 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 + ;; + *'#{pane_current_command}'*) printf 'claude\n' ;; +esac exit 0 SH chmod +x "$fakebin/tmux" diff --git a/tests/fm-spawn-launch-confirm.test.sh b/tests/fm-spawn-launch-confirm.test.sh new file mode 100755 index 00000000..7bd77985 --- /dev/null +++ b/tests/fm-spawn-launch-confirm.test.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# tests/fm-spawn-launch-confirm.test.sh - a typed launch is never its own proof. +# +# Portable regression for bin/fm-spawn.sh's launch confirmation. It runs the +# REAL fm-spawn.sh and fm-control.sh against a REAL tmux server on a private +# socket (`-L`) whose panes run a REAL interactive shell with no configuration, +# so it needs no harness and no credentials. The stand-in agent is a symlink to +# a long-running system binary named `claude`, which is exactly the process +# identity the tmux agent-state classifier reads. +# +# The defect: a launch command typed into a fresh pane arrived cut short in the +# middle of its `"$(...fm-operational-in` substitution, the shell sat at a +# `dquote cmdsubst>` continuation prompt, and the spawn still reported success. +# The next relaunch typed its own command into that open quote. A tmux shim +# reproduces the cut on the launch literal only, exactly where the incident's +# fell, and lets every other byte through to the real server. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v tmux >/dev/null 2>&1 || { echo "skip: tmux not found"; exit 0; } +SLEEP_BIN=$(command -v sleep) || { echo "skip: sleep not found"; exit 0; } +if PANE_SHELL=$(command -v zsh); then + PANE_SHELL_ARGS='-f' +elif PANE_SHELL=$(command -v bash); then + PANE_SHELL_ARGS='--norc --noprofile' +else + echo "skip: neither zsh nor bash found" + exit 0 +fi + +LAB=$(fm_test_tmproot fm-spawn-launch-confirm) +SOCKET="fm-launch-$$" +REAL_TMUX=$(command -v tmux) +TASK_IDS=() + +cleanup() { + local id + "$REAL_TMUX" -L "$SOCKET" kill-server >/dev/null 2>&1 || true + for id in "${TASK_IDS[@]:-}"; do + [ -n "$id" ] && rm -rf "/tmp/fm-$id" + done + fm_test_cleanup +} +trap cleanup EXIT + +mkdir -p "$LAB/bin" "$LAB/agent" "$LAB/home" +ln -s "$SLEEP_BIN" "$LAB/agent/claude" + +# Every bare `tmux` call reaches the private server. While the garble counter +# file holds a positive count, a launch literal loses everything from inside +# its operational-input substitution onward - the incident's exact cut. +cat > "$LAB/bin/tmux" < "$LAB/garble" + args[\$last]=\${payload%%operational-input*}operational-in + fi + ;; + esac +fi +exec "$REAL_TMUX" -L "$SOCKET" "\${args[@]}" +SH +# The pane's shell, unconfigured so nothing but typed input reaches it. +cat > "$LAB/bin/labshell" < "$LAB/bin/treehouse" < "$LAB/bin/claude" <> "$LAB/agent-starts" +exec "$LAB/agent/claude" 600 +SH +chmod +x "$LAB/bin/tmux" "$LAB/bin/labshell" "$LAB/bin/treehouse" "$LAB/bin/claude" + +PATH="$LAB/bin:$PATH" +export PATH +unset TMUX TMUX_PANE + +HOME="$LAB/home" "$REAL_TMUX" -L "$SOCKET" -f /dev/null \ + new-session -d -s firstmate -x 200 -y 50 -- "$LAB/bin/labshell" \ + || fail "could not start the private tmux server" +"$REAL_TMUX" -L "$SOCKET" set-option -g default-shell "$LAB/bin/labshell" >/dev/null \ + || fail "could not pin the private server's pane shell" + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-backend.sh" +fm_backend_source tmux || fail "fm_backend_source tmux failed" + +# new_case -> sets CASE_HOME, CASE_PROJ, CASE_WT +new_case() { + local name=$1 id=$2 dir + dir="$LAB/$name" + CASE_HOME="$dir/home" + CASE_PROJ="$dir/project" + CASE_WT="$dir/wt" + mkdir -p "$CASE_HOME/data/$id" "$CASE_HOME/projects" "$CASE_HOME/state" \ + "$CASE_HOME/config" "$CASE_HOME/user-home" + touch "$CASE_HOME/state/.last-watcher-beat" + fm_git_worktree "$CASE_PROJ" "$CASE_WT" "wt-$name" + cat > "$CASE_HOME/data/$id/brief.md" < "$LAB/next-wt" + TASK_IDS+=("$id") +} + +run_fm() { #