From 357396f0b8e44866202c6e8bd9a4303cbffc8c6e Mon Sep 17 00:00:00 2001 From: hey137 Date: Thu, 25 Jun 2026 19:44:03 +0800 Subject: [PATCH 1/2] Fix crewmate launch handshake checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fm-send falsely reported swallowed Enter for Codex because dim ghost stripping left the idle prompt glyph ›, but fm_tmux_composer_state did not treat that glyph as a bare prompt. Add it to the shared empty-composer classifier and cover the bold prompt plus dim suggestion rendering. fm-spawn could enter a treehouse worktree and print spawned even if the launch command never submitted, because it typed after cwd changed and never verified Enter landed. Reuse the shared tmux submit verifier and fail fast when launch text remains pending. --- bin/fm-spawn.sh | 26 +++- bin/fm-tmux-lib.sh | 6 +- tests/fm-composer-ghost.test.sh | 17 +++ tests/fm-spawn-send-handshake.test.sh | 164 ++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 5 deletions(-) create mode 100755 tests/fm-spawn-send-handshake.test.sh diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 452d503..c3e53d6 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -35,6 +35,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" SUB_HOME_MARKER=".fm-secondmate-home" +# shellcheck source=bin/fm-tmux-lib.sh +. "$SCRIPT_DIR/fm-tmux-lib.sh" # Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is # set by the batch loop below), so the guard runs once for the batch, not once per pair. [ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true @@ -344,6 +346,16 @@ if [ "$KIND" != secondmate ]; then echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 exit 1 fi + # cwd can flip before the new shell has drawn a prompt. Wait briefly for a + # readable idle/busy line before typing the agent launch, otherwise the launch + # text can be swallowed during the treehouse handoff while spawn still reports + # success. + for _ in $(seq 1 20); do + state=$(fm_tmux_composer_state "$T") + [ "$state" = empty ] && break + fm_pane_is_busy "$T" && break + sleep 0.2 + done fi # Per-harness turn-end hook: a file that touches state/.turn-ended when the @@ -439,8 +451,16 @@ if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" fi -tmux send-keys -t "$T" -l "$LAUNCH" -sleep 0.3 -tmux send-keys -t "$T" Enter +verdict=$(fm_tmux_submit_core "$T" "$LAUNCH" 3 0.4 0.3) +case "$verdict" in + pending) + echo "error: launch command was not submitted to $T (Enter swallowed; launch text left in composer)" >&2 + exit 1 + ;; + send-failed) + echo "error: launch command was not sent to $T (tmux send-keys failed)" >&2 + exit 1 + ;; +esac echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$T worktree=$WT" diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 374e358..5b659ba 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -136,9 +136,11 @@ fm_tmux_composer_state() { # -> empty|pending|unknown && printf '%s' "$stripped" | grep -qiE "$FM_COMPOSER_IDLE_RE"; then printf 'empty'; return 0 fi - # Just a bare prompt glyph = empty composer (idle). + # Just a bare prompt glyph = empty composer (idle). Codex uses `›` (U+203A); + # missing it caused post-submit idle panes to look pending and report a false + # "Enter swallowed" after the dim suggestion text was stripped. case "$stripped" in - '>'|'❯'|'$'|'%'|'#') printf 'empty'; return 0 ;; + '>'|'❯'|'›'|'$'|'%'|'#') printf 'empty'; return 0 ;; esac # A busy footer landing on the cursor line is not pending input. if printf '%s' "$stripped" | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}"; then diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index c6f2b55..1c12a40 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -125,6 +125,22 @@ test_dim_ghost_only_composer_is_not_pending() { pass "fm_pane_input_pending: a dim ghost-only composer is NOT pending" } +test_codex_bold_prompt_with_dim_suggestion_is_not_pending() { + local dir fb capture + dir="$TMP_ROOT/codex-bold-ghost"; mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + capture="$dir/styled.txt" + # Codex idle composer: normal/bold prompt glyph plus a dim suggestion. The + # root cause of the false "Enter swallowed" was that ghost stripping left the + # prompt glyph `›`, but bare-prompt detection did not know that glyph. + printf '\033[1m\xe2\x80\xba\033[0m \033[2mExplain this codebase\033[0m\n' > "$capture" + if PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=0 \ + fm_pane_input_pending "fakepane"; then + fail "codex bold prompt plus dim suggestion falsely read as pending" + fi + pass "fm_pane_input_pending: codex bold › plus dim suggestion is NOT pending" +} + test_dim_ghost_inside_bordered_composer_is_not_pending() { local dir fb capture dir="$TMP_ROOT/ghost-bordered"; mkdir -p "$dir" @@ -220,6 +236,7 @@ test_strip_ghost_drops_dim_keeps_normal test_strip_ghost_handles_combined_and_boundary_codes test_strip_ghost_keeps_colored_text_with_2_payloads test_dim_ghost_only_composer_is_not_pending +test_codex_bold_prompt_with_dim_suggestion_is_not_pending test_dim_ghost_inside_bordered_composer_is_not_pending test_normal_text_still_pending test_colored_text_with_2_payload_still_pending diff --git a/tests/fm-spawn-send-handshake.test.sh b/tests/fm-spawn-send-handshake.test.sh new file mode 100755 index 0000000..10b1472 --- /dev/null +++ b/tests/fm-spawn-send-handshake.test.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Regression coverage for launch/steer submission handshakes. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +SEND="$ROOT/bin/fm-send.sh" + +TMP_ROOT=$(fm_test_tmproot fm-spawn-send-handshake) + +make_submit_fake_tmux() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +log=${FM_FAKE_TMUX_LOG:?} +composer=${FM_FAKE_COMPOSER:?} +case "${1:-}" in + display-message) + for a in "$@"; do + case "$a" in + *cursor_y*) printf '0\n'; exit 0 ;; + *pane_current_path*) printf '%s\n' "${FM_FAKE_PANE_PATH:-$PWD}"; exit 0 ;; + '#S') printf 'firstmate\n'; exit 0 ;; + esac + done + printf 'firstmate\n'; exit 0 ;; + capture-pane) + cat "$composer" + exit 0 ;; + send-keys) + printf '%s\n' "$*" >> "$log" + shift + while [ $# -gt 0 ]; do + case "$1" in + -t) shift 2 ;; + -l) shift; printf '%s\n' "$1" > "$composer"; shift ;; + Enter) + if [ -n "${FM_FAKE_BUSY_AFTER_ENTER:-}" ]; then + printf 'Working...\n' > "$composer" + else + printf '\033[1m\xe2\x80\xba\033[0m \033[2mExplain this codebase\033[0m\n' > "$composer" + fi + shift ;; + *) shift ;; + esac + done + exit 0 ;; + list-windows) + [ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "$FM_FAKE_TMUX_WINDOW" + exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/tmux" + printf '%s\n' "$fakebin" +} + +make_spawn_fake_tmux() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +log=${FM_FAKE_TMUX_LOG:?} +composer=${FM_FAKE_COMPOSER:?} +case "${1:-}" in + has-session|new-session|new-window) + printf '%s\n' "$*" >> "$log" + exit 0 ;; + list-windows) + exit 0 ;; + display-message) + for a in "$@"; do + case "$a" in + *cursor_y*) printf '0\n'; exit 0 ;; + *pane_current_path*) printf '%s\n' "${FM_FAKE_WORKTREE:?}"; exit 0 ;; + esac + done + printf 'firstmate\n'; exit 0 ;; + capture-pane) + cat "$composer" + exit 0 ;; + send-keys) + printf '%s\n' "$*" >> "$log" + shift + while [ $# -gt 0 ]; do + case "$1" in + -t) shift 2 ;; + -l) shift; printf '%s\n' "$1" > "$composer"; shift ;; + Enter) + if [ -z "${FM_FAKE_KEEP_LAUNCH_PENDING:-}" ]; then + printf 'Working...\n' > "$composer" + fi + shift ;; + *) shift ;; + esac + done + exit 0 ;; +esac +exit 1 +SH + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/tmux" "$fakebin/treehouse" + printf '%s\n' "$fakebin" +} + +test_fm_send_accepts_codex_idle_prompt_after_submit() { + local dir home fakebin log composer err + dir="$TMP_ROOT/send-codex-idle"; mkdir -p "$dir" + home="$dir/home"; mkdir -p "$home/state" + log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/send.err" + printf '\033[1m\xe2\x80\xba\033[0m \033[2mExplain this codebase\033[0m\n' > "$composer" + fakebin=$(make_submit_fake_tmux "$dir") + PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \ + FM_SEND_SLEEP=0.01 "$SEND" sess:win 'route this work' >/dev/null 2>"$err" \ + || fail "fm-send reported a false swallowed Enter on codex idle prompt: $(cat "$err")" + pass "fm-send accepts codex idle/ghost prompt as submitted" +} + +test_fm_spawn_refuses_success_when_launch_text_remains_pending() { + local dir home project worktree fakebin log composer err out status + dir="$TMP_ROOT/spawn-pending"; home="$dir/home"; project="$home/projects/alpha"; worktree="$dir/worktree" + mkdir -p "$home/data/spawn-pending" "$home/state" "$project" "$worktree" + fm_git_init_commit "$worktree" + printf 'brief\n' > "$home/data/spawn-pending/brief.md" + log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/spawn.err"; out="$dir/spawn.out" + : > "$composer" + fakebin=$(make_spawn_fake_tmux "$dir") + PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \ + FM_FAKE_WORKTREE="$worktree" FM_FAKE_KEEP_LAUNCH_PENDING=1 \ + "$SPAWN" spawn-pending projects/alpha codex >"$out" 2>"$err" + status=$? + [ "$status" -ne 0 ] || fail "fm-spawn printed success while launch text was still pending" + assert_not_contains "$(cat "$out")" "spawned spawn-pending" "fm-spawn reported spawned despite pending launch" + assert_contains "$(cat "$err")" "launch command was not submitted" "fm-spawn did not explain pending launch text" + pass "fm-spawn fails instead of reporting success when launch submission stays pending" +} + +test_fm_spawn_reports_success_when_launch_submits_and_pane_busy() { + local dir home project worktree fakebin log composer err out + dir="$TMP_ROOT/spawn-busy"; home="$dir/home"; project="$home/projects/alpha"; worktree="$dir/worktree" + mkdir -p "$home/data/spawn-busy" "$home/state" "$project" "$worktree" + fm_git_init_commit "$worktree" + printf 'brief\n' > "$home/data/spawn-busy/brief.md" + log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/spawn.err"; out="$dir/spawn.out" + : > "$composer" + fakebin=$(make_spawn_fake_tmux "$dir") + PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \ + FM_FAKE_WORKTREE="$worktree" "$SPAWN" spawn-busy projects/alpha codex >"$out" 2>"$err" \ + || fail "fm-spawn failed after launch submission made pane busy: $(cat "$err")" + assert_contains "$(cat "$out")" "spawned spawn-busy" "fm-spawn did not report success after busy launch" + pass "fm-spawn reports success after launch command submits and pane is busy" +} + +test_fm_send_accepts_codex_idle_prompt_after_submit +test_fm_spawn_refuses_success_when_launch_text_remains_pending +test_fm_spawn_reports_success_when_launch_submits_and_pane_busy From 69e51c7925398479f9765a3fdbf858577e08a14d Mon Sep 17 00:00:00 2001 From: hey137 Date: Fri, 26 Jun 2026 19:06:49 +0800 Subject: [PATCH 2/2] Refuse steering busy panes fm-send could type steering into a busy Codex pane while the agent was waiting on a tool/background terminal. Codex queued that text for the next tool-call composer, then fm-send saw pending text and misdiagnosed Enter as swallowed. Guard the shared tmux submit primitive with fm_pane_is_busy before typing anything. Busy returns a distinct verdict so fm-send and fm-spawn fail fast without writing into the composer. --- bin/fm-send.sh | 4 ++++ bin/fm-spawn.sh | 4 ++++ bin/fm-tmux-lib.sh | 5 ++++- tests/fm-spawn-send-handshake.test.sh | 32 +++++++++++++++++++++++---- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 8e651ca..1af1ea5 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -65,5 +65,9 @@ else echo "error: text not sent to $T (tmux send-keys failed)" >&2 exit 1 ;; + busy) + echo "error: text not sent to $T (target pane busy)" >&2 + exit 1 + ;; esac fi diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index c3e53d6..4014a1e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -461,6 +461,10 @@ case "$verdict" in echo "error: launch command was not sent to $T (tmux send-keys failed)" >&2 exit 1 ;; + busy) + echo "error: launch command was not sent to $T (target pane busy)" >&2 + exit 1 + ;; esac echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$T worktree=$WT" diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 5b659ba..1cb49d5 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -168,8 +168,10 @@ fm_pane_is_busy() { # # fm_tmux_submit_core: type into ONCE, then submit with Enter, # verifying the composer cleared. Retries Enter ONLY — never retypes, because a # swallowed Enter leaves our text in the composer and retyping would duplicate -# it. Echoes the final verdict on stdout (empty|pending|unknown|send-failed) so callers can +# it. Echoes the final verdict on stdout (empty|pending|unknown|busy|send-failed) so callers can # pick their own success policy: +# - busy means nothing was typed; steering a pane mid-turn queues text into the +# next tool-call composer and creates a false swallowed-Enter diagnosis. # - the daemon clears its buffer only on "empty" (strict: an unknown pane must # not be mistaken for a delivered escalation). # - fm-send fails only on "pending" (lenient: a positively-confirmed swallow), @@ -188,6 +190,7 @@ fm_tmux_submit_enter_core() { # fm_tmux_submit_core() { # local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 + fm_pane_is_busy "$target" && { printf 'busy'; return 0; } tmux send-keys -t "$target" -l "$text" 2>/dev/null || { printf 'send-failed'; return 0; } sleep "$settle" fm_tmux_submit_enter_core "$target" "$retries" "$sleep_s" diff --git a/tests/fm-spawn-send-handshake.test.sh b/tests/fm-spawn-send-handshake.test.sh index 10b1472..459851d 100755 --- a/tests/fm-spawn-send-handshake.test.sh +++ b/tests/fm-spawn-send-handshake.test.sh @@ -29,6 +29,10 @@ case "${1:-}" in done printf 'firstmate\n'; exit 0 ;; capture-pane) + if [ "${*: -1}" = "-40" ] || [ -n "${FM_FAKE_BUSY_TAIL:-}" ]; then + printf '%s\n' "${FM_FAKE_BUSY_TAIL:-}" + exit 0 + fi cat "$composer" exit 0 ;; send-keys) @@ -90,9 +94,9 @@ case "${1:-}" in while [ $# -gt 0 ]; do case "$1" in -t) shift 2 ;; - -l) shift; printf '%s\n' "$1" > "$composer"; shift ;; + -l) shift; printf '%s\n' "$1" > "$composer"; touch "${FM_FAKE_LAUNCH_TYPED:-/dev/null}"; shift ;; Enter) - if [ -z "${FM_FAKE_KEEP_LAUNCH_PENDING:-}" ]; then + if [ -n "${FM_FAKE_LAUNCH_TYPED:-}" ] && [ -e "$FM_FAKE_LAUNCH_TYPED" ] && [ -z "${FM_FAKE_KEEP_LAUNCH_PENDING:-}" ]; then printf 'Working...\n' > "$composer" fi shift ;; @@ -124,6 +128,24 @@ test_fm_send_accepts_codex_idle_prompt_after_submit() { pass "fm-send accepts codex idle/ghost prompt as submitted" } +test_fm_send_refuses_busy_pane_without_typing() { + local dir home fakebin log composer err + dir="$TMP_ROOT/send-busy"; mkdir -p "$dir" + home="$dir/home"; mkdir -p "$home/state" + log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/send.err" + : > "$log" + printf '\033[1m\xe2\x80\xba\033[0m\n' > "$composer" + fakebin=$(make_submit_fake_tmux "$dir") + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \ + FM_FAKE_BUSY_TAIL="esc to interrupt" FM_SEND_SLEEP=0.01 \ + "$SEND" sess:win 'do not queue this while busy' >/dev/null 2>"$err"; then + fail "fm-send exited zero for a busy pane" + fi + assert_contains "$(cat "$err")" "target pane busy" "fm-send did not explain busy target refusal" + assert_not_contains "$(cat "$log")" "send-keys -t sess:win -l" "fm-send typed steering text into a busy pane" + pass "fm-send refuses a busy pane without typing into the composer" +} + test_fm_spawn_refuses_success_when_launch_text_remains_pending() { local dir home project worktree fakebin log composer err out status dir="$TMP_ROOT/spawn-pending"; home="$dir/home"; project="$home/projects/alpha"; worktree="$dir/worktree" @@ -134,7 +156,7 @@ test_fm_spawn_refuses_success_when_launch_text_remains_pending() { : > "$composer" fakebin=$(make_spawn_fake_tmux "$dir") PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \ - FM_FAKE_WORKTREE="$worktree" FM_FAKE_KEEP_LAUNCH_PENDING=1 \ + FM_FAKE_WORKTREE="$worktree" FM_FAKE_LAUNCH_TYPED="$dir/launch-typed" FM_FAKE_KEEP_LAUNCH_PENDING=1 \ "$SPAWN" spawn-pending projects/alpha codex >"$out" 2>"$err" status=$? [ "$status" -ne 0 ] || fail "fm-spawn printed success while launch text was still pending" @@ -153,12 +175,14 @@ test_fm_spawn_reports_success_when_launch_submits_and_pane_busy() { : > "$composer" fakebin=$(make_spawn_fake_tmux "$dir") PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \ - FM_FAKE_WORKTREE="$worktree" "$SPAWN" spawn-busy projects/alpha codex >"$out" 2>"$err" \ + FM_FAKE_WORKTREE="$worktree" FM_FAKE_LAUNCH_TYPED="$dir/launch-typed" \ + "$SPAWN" spawn-busy projects/alpha codex >"$out" 2>"$err" \ || fail "fm-spawn failed after launch submission made pane busy: $(cat "$err")" assert_contains "$(cat "$out")" "spawned spawn-busy" "fm-spawn did not report success after busy launch" pass "fm-spawn reports success after launch command submits and pane is busy" } test_fm_send_accepts_codex_idle_prompt_after_submit +test_fm_send_refuses_busy_pane_without_typing test_fm_spawn_refuses_success_when_launch_text_remains_pending test_fm_spawn_reports_success_when_launch_submits_and_pane_busy