From 5ef6c7afe2b27667ac725ba0ee836b672fcb0874 Mon Sep 17 00:00:00 2001 From: marano Date: Sat, 19 Sep 2026 11:53:29 -0300 Subject: [PATCH 1/2] fix(bin): confirm a spawned agent started and clear a stuck shell prompt before relaunch A launch typed into a pane can arrive cut short - on macOS a line over the 1024-byte canonical-input limit typed before the shell's line editor runs is truncated - leaving the shell at a continuation prompt with no agent, while fm-spawn.sh still reported success. The next relaunch then typed into the same open quote. On tmux, where the agent-state classifier proves an agent from the pane's own processes, every launch now waits for the agent to read alive, retries once in place after clearing the shell's pending input, and otherwise fails, closing a fresh spawn's endpoint and rolling back its record. A relaunch clears the adopted shell's pending input before typing anything. --- bin/fm-backend.sh | 15 ++ bin/fm-spawn.sh | 97 ++++++++- docs/agent-control.md | 1 + tests/fixtures.sh | 12 ++ tests/fm-agy-harness.test.sh | 10 +- tests/fm-backend.test.sh | 18 +- tests/fm-backlog-atomicity.test.sh | 18 +- tests/fm-kimi-harness.test.sh | 10 +- tests/fm-muse-harness.test.sh | 10 +- tests/fm-rovo-harness.test.sh | 10 +- tests/fm-secondmate-harness.test.sh | 26 ++- tests/fm-secondmate-liveness.test.sh | 10 +- tests/fm-secondmate-sync.test.sh | 23 +- tests/fm-shared-captain-inheritance.test.sh | 12 ++ tests/fm-spawn-launch-confirm.test.sh | 228 ++++++++++++++++++++ tests/fm-spawn-worktree-settle.test.sh | 15 +- tests/fm-tangle-guard.test.sh | 10 +- tests/fm-trace-context-spawn.test.sh | 20 +- tests/secondmate-helpers.sh | 32 ++- 19 files changed, 554 insertions(+), 23 deletions(-) create mode 100755 tests/fm-spawn-launch-confirm.test.sh 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-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..a67fd5a7 --- /dev/null +++ b/tests/fm-spawn-launch-confirm.test.sh @@ -0,0 +1,228 @@ +#!/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() { #