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 452d503..4014a1e 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,20 @@ 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 + ;; + 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 374e358..1cb49d5 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 @@ -166,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), @@ -186,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-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..459851d --- /dev/null +++ b/tests/fm-spawn-send-handshake.test.sh @@ -0,0 +1,188 @@ +#!/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) + 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) + 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"; touch "${FM_FAKE_LAUNCH_TYPED:-/dev/null}"; shift ;; + Enter) + if [ -n "${FM_FAKE_LAUNCH_TYPED:-}" ] && [ -e "$FM_FAKE_LAUNCH_TYPED" ] && [ -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_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" + 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_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" + 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" 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