diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 12dc7629eb6..c1f57939323 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -504,8 +504,8 @@ fm_backend_cmux_send_key() { # [expected-label] # fm_backend_cmux_send_text_line: send one line of TEXT then submit. cmux has # no single-call atomic "run and submit" primitive (like herdr's `pane run`), # so this composes send (literal) + send-key enter, exactly like zellij's -# equivalent - used for the fixed spawn-time commands (treehouse get, the -# GOTMPDIR export). +# equivalent. fm-spawn uses this primitive for setup and the launch delivery +# protocol owned by bin/fm-spawn.sh's header. fm_backend_cmux_send_text_line() { # [expected-label] fm_backend_cmux_send_literal "$1" "$2" "${3:-}" || return 1 fm_backend_cmux_send_key "$1" Enter "${3:-}" diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 237d2348c5a..8b44b3c6462 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1676,9 +1676,9 @@ fm_backend_herdr_current_path() { # } # fm_backend_herdr_send_text_line: send one line of TEXT then submit, -# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed -# spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types -# the command and submits it in one call (verified). +# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. fm-spawn uses this +# primitive for setup and the launch delivery protocol owned by bin/fm-spawn.sh's +# header. `pane run` types and submits in one call (verified). fm_backend_herdr_send_text_line() { # fm_backend_herdr_target_ready "$1" || return 1 fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane run "$FM_BACKEND_HERDR_PANE" "$2" >/dev/null 2>&1 diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index f8da21bf0de..0433fc587fa 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -102,16 +102,15 @@ fm_backend_tmux_current_path() { # } # fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no -# composer verification - used for the fixed spawn-time commands -# (`treehouse get`, the GOTMPDIR export) that already ran this exact sequence -# inline in fm-spawn.sh. Mirrors `tmux send-keys -t "$T" "" Enter`. +# composer verification. fm-spawn uses this primitive for setup and the launch +# delivery protocol owned by bin/fm-spawn.sh's header. +# Mirrors `tmux send-keys -t "$T" "" Enter`. fm_backend_tmux_send_text_line() { # tmux send-keys -t "$1" "$2" Enter } # fm_backend_tmux_send_literal: send TEXT as literal bytes with no -# submission - the caller sends Enter separately (fm-spawn.sh's launch-command -# send pauses between the literal send and Enter for the harness to settle). +# submission - the caller sends Enter separately. # Mirrors `tmux send-keys -t "$T" -l ""`. fm_backend_tmux_send_literal() { # tmux send-keys -t "$1" -l "$2" diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 20d53a3c2de..60aea88e79b 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -462,11 +462,12 @@ fm_backend_zellij_send_key() { # [expected-label] # fm_backend_zellij_send_text_line: send one line of TEXT then submit, # ATOMICALLY - mirrors tmux's `send-keys -t T text Enter` / herdr's `pane -# run`. Used for the fixed spawn-time commands (treehouse get, the GOTMPDIR -# export). Zellij has no single-call atomic "run and submit" action, so this -# composes paste (literal) + send-keys Enter, exactly like send_literal + -# send_key are composed elsewhere - the two-step form is the ONLY form for -# this adapter, unlike tmux/herdr which have a genuinely atomic primitive. +# run`. fm-spawn uses this primitive for setup and the launch delivery protocol +# owned by bin/fm-spawn.sh's header. Zellij has no single-call atomic "run and +# submit" action, so this composes paste (literal) + send-keys Enter, exactly +# like send_literal + send_key are composed elsewhere - the two-step form is +# the ONLY form for this adapter, unlike tmux/herdr which have a genuinely +# atomic primitive. fm_backend_zellij_send_text_line() { # [expected-label] fm_backend_zellij_send_literal "$1" "$2" "${3:-}" || return 1 fm_backend_zellij_send_key "$1" Enter "${3:-}" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 2e80a552b9e..1c3a860ae64 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -67,6 +67,14 @@ # whitespace is treated as a RAW launch command - the escape hatch for verifying # new adapters. pi-signed launches that exact executable name from PATH and # refuses before endpoint creation when it is unavailable; it never falls back to pi. +# Before any harness command runs, launch delivery waits for an executed shell +# round-trip, stages the command through bounded submitted assignments using the +# selected backend's send/capture primitives, and verifies the complete staged +# bytes before evaluation. A failed check clears the shell line and retries a +# bounded number of times; exhaustion records a failed task status and exits +# nonzero rather than reporting a spawn. This launch-delivery contract does not +# alter the backend adapters' text-submit verification contracts; its regression +# coverage lives in tests/fm-spawn-launch-delivery.test.sh. # config/secondmate-harness may also carry an optional model and effort as extra # whitespace-separated tokens (" [] []"). For a # --secondmate spawn, those tokens apply only when this spawn also resolves its @@ -1236,6 +1244,85 @@ spawn_send_key() { # esac } +spawn_capture() { # + fm_backend_capture "$BACKEND" "$1" 160 "$W" 2>/dev/null || true +} + +spawn_capture_has_line() { # + printf '%s\n' "$1" | grep -Fqx "$2" +} + +spawn_wait_for_marker() { # + local target=$1 marker=$2 pane i=0 max=${FM_SPAWN_LAUNCH_VERIFY_POLLS:-40} + case "$max" in ''|*[!0-9]*) max=40 ;; esac + [ "$max" -gt 0 ] || max=40 + while [ "$i" -lt "$max" ]; do + pane=$(spawn_capture "$target") + spawn_capture_has_line "$pane" "$marker" && return 0 + i=$((i + 1)) + [ "$i" -ge "$max" ] || sleep "${FM_SPAWN_LAUNCH_POLL_INTERVAL:-0.05}" + done + return 1 +} + +spawn_wait_for_shell_ready() { # + local target=$1 token=$2 + local marker="__FM_SPAWN_READY_$token" + # The full marker is intentionally absent from the typed command, so seeing + # an exact marker line proves the shell executed the probe rather than merely + # echoing bytes that arrived before its line editor was ready. + spawn_send_text_line "$target" "printf '%s%s\\n' '__FM_SPAWN_READY_' '$token'" \ + || return 1 + spawn_wait_for_marker "$target" "$marker" +} + +spawn_stage_launch() { # + local target=$1 launch=$2 token=$3 chunk_size=${FM_SPAWN_LAUNCH_CHUNK_BYTES:-160} + local delay=${FM_SPAWN_LAUNCH_CHUNK_DELAY:-0.04} offset=0 chunk quoted expected marker check + case "$chunk_size" in ''|*[!0-9]*) chunk_size=160 ;; esac + [ "$chunk_size" -gt 0 ] || chunk_size=160 + expected=$(printf '%s' "$launch" | cksum) || return 1 + marker="__FM_SPAWN_LAUNCH_OK_$token" + + # C-c clears an abandoned line from a prior failed attempt before short, + # independently submitted assignments rebuild the launch exactly in the + # target shell. This avoids macOS's pre-ZLE canonical-input ceiling while + # retaining the backend adapters' own literal/key submission contracts. + spawn_send_key "$target" C-c || true + spawn_send_text_line "$target" "FM_SPAWN_LAUNCH=''" || return 1 + while [ "$offset" -lt "${#launch}" ]; do + chunk=${launch:offset:chunk_size} + quoted=$(shell_quote "$chunk") + spawn_send_text_line "$target" "FM_SPAWN_LAUNCH=\"\${FM_SPAWN_LAUNCH}\"$quoted" || return 1 + offset=$((offset + chunk_size)) + sleep "$delay" + done + check="if [ \"\$(printf %s \"\$FM_SPAWN_LAUNCH\" | cksum)\" = $(shell_quote "$expected") ]; then printf '%s%s\\n' '__FM_SPAWN_LAUNCH_OK_' '$token'; else printf '%s%s\\n' '__FM_SPAWN_LAUNCH_BAD_' '$token'; fi" + spawn_send_text_line "$target" "$check" || return 1 + spawn_wait_for_marker "$target" "$marker" +} + +spawn_deliver_launch() { # + local target=$1 launch=$2 retries=${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3} + local token token_sum attempt=1 + case "$retries" in ''|*[!0-9]*) retries=3 ;; esac + [ "$retries" -gt 0 ] || retries=3 + while [ "$attempt" -le "$retries" ]; do + token_sum=$(printf '%s' "$RANDOM:$attempt" | cksum) || return 1 + token_sum=${token_sum%% *} + printf -v token '%010u' "$token_sum" + if spawn_wait_for_shell_ready "$target" "$token" \ + && spawn_stage_launch "$target" "$launch" "$token"; then + # shellcheck disable=SC2016 # Expand the staged launch in the target shell. + spawn_send_text_line "$target" 'eval "$FM_SPAWN_LAUNCH"' || return 1 + return 0 + fi + spawn_send_key "$target" C-c || true + attempt=$((attempt + 1)) + done + return 1 +} + kimi_capture() { fm_backend_capture "$BACKEND" "$T" 120 "$W" 2>/dev/null || true } @@ -1555,17 +1642,20 @@ if [ "$KIND" = secondmate ]; then LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" fi # 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. +# process (go build, go test, ...) inherit it. The verified delivery routine +# below then waits for that shell to round-trip a probe before staging the full +# launch in bounded, independently submitted assignments. spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" -sleep 0.3 -spawn_send_literal "$T" "$LAUNCH" -sleep 0.3 +if ! spawn_deliver_launch "$T" "$LAUNCH"; then + printf 'failed: launch command delivery could not be verified after %s attempts\n' \ + "${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3}" >> "$STATE/$ID.status" + echo "error: launch command delivery could not be verified after ${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3} attempts; inspect window $T" >&2 + exit 1 +fi if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then HERDR_PROJECTION_ABORT_CLEANUP=0 spawn_herdr_presentation_order_lock_release fi -spawn_send_key "$T" Enter 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/herdr-backend.md b/docs/herdr-backend.md index 0ba7ff6316c..07a3b1ae7d8 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -164,7 +164,7 @@ Every Herdr invocation goes through `fm_backend_herdr_cli`, which sets the envir An environment variable alone is not reliable when another Herdr server is running. Literal text and Enter are separate operations for ordinary steers. -Spawn-time fixed commands may use Herdr's atomic run primitive. +Spawn setup and the verified launch-delivery protocol use Herdr's atomic run primitive, while [`bin/fm-spawn.sh`](../bin/fm-spawn.sh) owns the protocol. Enter, Escape, and Ctrl-C are supported. Slash and dollar-prefixed input uses the shared harness-aware settle before the first Enter so a completion popup cannot consume it. Text is typed once; only Enter is retried. diff --git a/tests/fm-backend-herdr-smoke.test.sh b/tests/fm-backend-herdr-smoke.test.sh index 9eef7a90242..466aafd9be5 100755 --- a/tests/fm-backend-herdr-smoke.test.sh +++ b/tests/fm-backend-herdr-smoke.test.sh @@ -255,7 +255,7 @@ case "$out" in esac pass "real herdr: send_text_line runs a command atomically (pane run) and its output is capturable" -# --- send_literal + send_key(Enter), the two-step launch-command form ------- +# --- send_literal + send_key(Enter), the two-step primitive composition ----- fm_backend_herdr_send_literal "$TARGET" 'echo literal-then-key-captain' \ || fail "send_literal failed" diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index a54e448d108..17a20971daa 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -27,6 +27,45 @@ if [ "${1:-}" = status ] && [ "${FM_ORCA_STATUS_RESPONSE:-ready}" != sequence ]; printf '{"ok":true,"result":{"runtime":{"reachable":true,"state":"ready"}}}\n' exit 0 fi +if [ -n "${FM_ORCA_SPAWN_SCREEN:-}" ] && [ "${1:-} ${2:-}" = "terminal send" ]; then + text= + prev= + for arg in "$@"; do + if [ "$prev" = --text ]; then + text=$arg + break + fi + prev=$arg + done + staged="$FM_ORCA_SPAWN_SCREEN.staged" + evaluated="$FM_ORCA_SPAWN_SCREEN.evaluated" + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$FM_ORCA_SPAWN_SCREEN" + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged" + ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text" > "$FM_ORCA_SPAWN_SCREEN" + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged" > "$evaluated" + ;; + esac + printf '{"ok":true,"result":{"send":{"accepted":true}}}\n' + exit 0 +fi +if [ -n "${FM_ORCA_SPAWN_SCREEN:-}" ] && [ "${1:-} ${2:-}" = "terminal read" ]; then + marker=$(tail -n 1 "$FM_ORCA_SPAWN_SCREEN" 2>/dev/null || true) + printf '{"ok":true,"result":{"terminal":{"tail":["%s"]}}}\n' "$marker" + exit 0 +fi n=$next echo "$n" > "$COUNT_FILE" if [ -f "$RESP/$n.exit" ]; then @@ -486,7 +525,11 @@ test_spawn_writes_orca_metadata_and_launches_harness() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-spawn"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn","path":"%s"},"terminal":{"handle":"term-spawn"}}}\n' "$wt" > "$RESP/3.out" + : > "$CASE_DIR/spawn-screen" + : > "$CASE_DIR/spawn-screen.staged" + : > "$CASE_DIR/spawn-screen.evaluated" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + FM_ORCA_SPAWN_SCREEN="$CASE_DIR/spawn-screen" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) @@ -502,8 +545,9 @@ test_spawn_writes_orca_metadata_and_launches_harness() { "spawn should reuse the implicit terminal returned by Orca worktree creation" assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ "spawn did not export GOTMPDIR through the Orca terminal" - assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ - "spawn did not send the selected harness launch command through Orca" + assert_contains "$(cat "$CASE_DIR/spawn-screen.evaluated")" \ + "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ + "spawn did not verify and evaluate the selected harness launch command through Orca" rm -rf "/tmp/fm-$id" pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" } diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index aa1e07c3269..d9a5af774c4 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -105,8 +105,7 @@ case "$out" in esac pass "real tmux: fm_backend_tmux_send_text_line sends literal text and submits with Enter" -# --- send_literal + send_key(Enter), the two-step form fm-spawn.sh uses for the -# harness launch command (literal send, settle, then a separate Enter) -------- +# --- send_literal + send_key(Enter), the two-step primitive composition ------ fm_backend_tmux_send_literal "$TARGET" "printf 'literal-then-key-%s\\n' captain" \ || fail "fm_backend_tmux_send_literal failed" diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 1f98176b429..84ba7acad2d 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -797,11 +797,26 @@ make_spawn_fakebin() { # -> echoes fakebin dir #!/usr/bin/env bash set -u { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" +screen="\${FM_TMUX_LOG:?}.spawn-screen" case "\${1:-}" in display-message) 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 ;; + capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;; + send-keys) + text=\${4:-} + case "\$text" in + *"__FM_SPAWN_READY_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen" + ;; + esac + exit 0 ;; esac exit 0 SH @@ -859,6 +874,7 @@ make_spawn_symlink_fakebin() { # - #!/usr/bin/env bash set -u { printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}" +screen="\${FM_TMUX_LOG:?}.spawn-screen" case "\${1:-}" in display-message) for a in "\$@"; do case "\$a" in *pane_current_path*) @@ -872,6 +888,20 @@ case "\${1:-}" in ;; esac; done printf 'firstmate\\n'; exit 0 ;; list-windows) exit 0 ;; + capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;; + send-keys) + text=\${4:-} + case "\$text" in + *"__FM_SPAWN_READY_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p") + [ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen" + ;; + esac + exit 0 ;; esac exit 0 SH diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 8e27052d8ce..105a5a9219a 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -29,6 +29,8 @@ make_spawn_fakebin() { set -u printf '%s\n' "$*" >> "$FM_FAKE_TMUX_CALL_LOG" state=$(cat "$FM_FAKE_KIMI_STATE" 2>/dev/null || true) +spawn_screen=${FM_FAKE_SPAWN_SCREEN:-"${FM_FAKE_LAUNCH_LOG}.screen"} +staged_launch=${FM_FAKE_STAGED_LAUNCH:-"${FM_FAKE_LAUNCH_LOG}.staged"} fake_screen() { case "$state" in ready) @@ -80,13 +82,43 @@ case "${1:-}" in esac exit 0 fi + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$spawn_screen" + exit 0 + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged_launch" + exit 0 + ;; + FM_SPAWN_LAUNCH=*) + staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$staged" > "$staged_launch" + exit 0 + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$spawn_screen" + exit 0 + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged_launch" >> "$FM_FAKE_LAUNCH_LOG" + printf '\n' >> "$FM_FAKE_LAUNCH_LOG" + if [ "${FM_FAKE_KIMI_READY:-yes}" = yes ]; then + printf 'ready\n' > "$FM_FAKE_KIMI_STATE" + else + printf 'launched\n' > "$FM_FAKE_KIMI_STATE" + fi + exit 0 + ;; + esac case " $* " in *' Enter '*) case "$state" in - launched) - if [ "${FM_FAKE_KIMI_READY:-yes}" = yes ]; then - printf 'ready\n' > "$FM_FAKE_KIMI_STATE" - fi + ready) + printf 'unexpected-enter\n' > "$FM_FAKE_KIMI_STATE" ;; pointer-typed) if [ "${FM_FAKE_KIMI_DELIVERY:-yes}" = yes ]; then @@ -114,9 +146,12 @@ case "${1:-}" in esac case "$arg" in -S|-E) prev=$arg ;; *) prev= ;; esac done - case "$start:$end" in - *[!0-9:]*|'':*|*:'') fake_screen ;; - *) fake_screen | awk -v start="$start" -v end="$end" \ + { + cat "$spawn_screen" 2>/dev/null || true + fake_screen + } | case "$start:$end" in + *[!0-9:]*|'':*|*:'') cat ;; + *) awk -v start="$start" -v end="$end" \ 'NR - 1 >= start && NR - 1 <= end' ;; esac exit 0 @@ -146,6 +181,8 @@ make_spawn_case() { touch "$home/state/.last-watcher-beat" : > "$case_dir/launch.log" : > "$case_dir/pointer.log" + : > "$case_dir/launch.log.screen" + : > "$case_dir/launch.log.staged" : > "$case_dir/kimi.state" : > "$case_dir/tmux-calls.log" printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" @@ -165,6 +202,7 @@ run_spawn() { FM_FAKE_KIMI_SWALLOW_FIRST="${FM_FAKE_KIMI_SWALLOW_FIRST:-no}" \ FM_FAKE_TMUX_CALL_LOG="$case_dir/tmux-calls.log" \ FM_FAKE_BRIEF_REAL="$(cd "$home/data/$id" && pwd -P)/brief.md" \ + FM_FAKE_SPAWN_SCREEN="$case_dir/launch.log.screen" FM_FAKE_STAGED_LAUNCH="$case_dir/launch.log.staged" \ FM_KIMI_READY_POLLS=2 FM_KIMI_DELIVERY_POLLS=2 FM_KIMI_POLL_INTERVAL=0 \ PATH="$fakebin:$BASE_PATH" \ "$SPAWN" "$id" "$proj" --harness kimi "$@" 2>&1 diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index e5f017608dc..3d2fbb3c361 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -19,6 +19,8 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +screen=${FM_FAKE_SPAWN_SCREEN:-"${FM_FAKE_LAUNCH_LOG:?}.screen"} +staged_launch=${FM_FAKE_STAGED_LAUNCH:-"${FM_FAKE_LAUNCH_LOG:?}.staged"} case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac @@ -26,7 +28,35 @@ case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; + capture-pane) cat "$screen"; exit 0 ;; send-keys) + text=${4:-} + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$screen" + exit 0 + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged_launch" + exit 0 + ;; + FM_SPAWN_LAUNCH=*) + staged=$(FM_SPAWN_LAUNCH="$(cat "$staged_launch")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$staged" > "$staged_launch" + exit 0 + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$screen" + exit 0 + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged_launch" >> "$FM_FAKE_LAUNCH_LOG" + printf '\n' >> "$FM_FAKE_LAUNCH_LOG" + exit 0 + ;; + esac if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then prev= for a in "$@"; do @@ -84,6 +114,8 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" + : > "$launchlog.screen" + : > "$launchlog.staged" # CLAUDE_CONFIG_DIR is forwarded onto claude launches by fm-spawn, so pin it # explicitly (empty by default) instead of leaking the invoking shell's value, # which would make launch assertions depend on the developer's environment. @@ -94,6 +126,7 @@ run_spawn() { FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ + FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ "$SPAWN" "$@" 2>&1 } @@ -144,6 +177,7 @@ test_relative_home_overrides_launch_with_absolute_cross_process_paths() { FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$id" "$PROJ_DIR" 2>&1 ) @@ -173,6 +207,7 @@ test_home_defaults_preserve_absolute_or_resolve_relative_paths() { FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + FM_FAKE_SPAWN_SCREEN="$CASE_DIR/spawn.screen" FM_FAKE_STAGED_LAUNCH="$CASE_DIR/staged-launch" \ GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 ) diff --git a/tests/fm-spawn-launch-delivery.test.sh b/tests/fm-spawn-launch-delivery.test.sh new file mode 100644 index 00000000000..495b82da82c --- /dev/null +++ b/tests/fm-spawn-launch-delivery.test.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# Behavior tests for fm-spawn's verified long-launch delivery protocol. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-launch-delivery) + +make_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u + +screen=${FM_FAKE_SCREEN:?} +log=${FM_FAKE_TMUX_LOG:?} +attempts=${FM_FAKE_ATTEMPTS:?} +staged=${FM_FAKE_STAGED:?} +evaluated=${FM_FAKE_EVALUATED:?} +screen_history=${FM_FAKE_SCREEN_HISTORY:?} +write_screen_line() { + printf '%s\n' "$1" | fold -w "${FM_FAKE_PANE_COLUMNS:-80}" > "$screen" + cat "$screen" >> "$screen_history" +} +case "${1:-}" in + display-message) + case "$*" in + *'#{pane_current_path}'*) printf '%s\n' "$FM_FAKE_PANE_PATH" ;; + *) printf 'firstmate\n' ;; + esac + exit 0 + ;; + new-window) printf '@7\n'; exit 0 ;; + list-windows|has-session|set-window-option|kill-window) exit 0 ;; + capture-pane) cat "$screen"; exit 0 ;; + send-keys) + text=${4:-} + printf '%s\n' "$text" >> "$log" + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -n "$token" ] && write_screen_line "__FM_SPAWN_READY_$token" + ;; + "FM_SPAWN_LAUNCH=''" ) + count=$(($(cat "$attempts" 2>/dev/null || printf 0) + 1)) + printf '%s\n' "$count" > "$attempts" + : > "$staged" + ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + if [ "$(cat "$attempts")" -le "${FM_FAKE_TRUNCATE_ATTEMPTS:-0}" ] && [ -n "$rebuilt" ]; then + rebuilt=${rebuilt%?} + fi + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + result=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text") + if [ "$(cat "$attempts")" -le "${FM_FAKE_TRUNCATE_ATTEMPTS:-0}" ]; then + # The fixed-offset env-scrub prefix is the live truncation signature. + printf '/usr/bin/env -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE FM_H\n%s\n' "$result" > "$screen" + cat "$screen" >> "$screen_history" + else + write_screen_line "$result" + fi + ;; + 'eval "$FM_SPAWN_LAUNCH"') + FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text" > "$evaluated" + ;; + esac + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +make_case() { + local name=$1 id=$2 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_fakebin "$case_dir/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + printf 'claude\n' > "$home/config/crew-harness" + printf 'brief\n' > "$home/data/$id/brief.md" + touch "$home/state/.last-watcher-beat" + fm_git_worktree "$proj" "$wt" "wt-$name" + : > "$case_dir/screen" + : > "$case_dir/tmux.log" + : > "$case_dir/attempts" + : > "$case_dir/staged" + : > "$case_dir/evaluated" + : > "$case_dir/screen-history" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +run_spawn() { + local case_dir=$1 home=$2 proj=$3 wt=$4 fakebin=$5 id=$6 + shift 6 + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_FAKE_SCREEN="$case_dir/screen" FM_FAKE_TMUX_LOG="$case_dir/tmux.log" \ + FM_FAKE_ATTEMPTS="$case_dir/attempts" FM_FAKE_STAGED="$case_dir/staged" \ + FM_FAKE_EVALUATED="$case_dir/evaluated" FM_FAKE_SCREEN_HISTORY="$case_dir/screen-history" \ + FM_SPAWN_LAUNCH_POLL_INTERVAL=0 FM_SPAWN_LAUNCH_CHUNK_DELAY=0 \ + PATH="$fakebin:$PATH" \ + "$SPAWN" "$id" "$proj" "$@" 2>&1 +} + +read_case() { + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR < 450 { exit 1 }' "$CASE_DIR/tmux.log" \ + || fail "fm-spawn sent a line larger than the bounded delivery chunk" + assert_grep 'C-c' "$CASE_DIR/tmux.log" "failed delivery did not clear the pending shell line before retry" + [ "$(wc -c < "$CASE_DIR/evaluated" | tr -d ' ')" = 1801 ] \ + || fail "verified retry did not evaluate the complete 1,800-byte payload" + [ -z "$(tr -d 'x\n' < "$CASE_DIR/evaluated")" ] \ + || fail "verified retry evaluated bytes other than the complete launch payload" + assert_contains "$out" "spawned $id" "verified retry did not finish the spawn" + if [ -n "${FM_TEST_EVIDENCE_DIR:-}" ]; then + mkdir -p "$FM_TEST_EVIDENCE_DIR" + { + printf '$ fm-spawn.sh %s <1,800-byte raw launch>\n' "$id" + printf '%s\n' "$out" + printf '\nObserved shell output across delivery attempts:\n' + cat "$CASE_DIR/screen-history" + printf '\nDelivery facts:\n' + printf 'attempts=%s\n' "$(cat "$CASE_DIR/attempts")" + printf 'largest_typed_line_bytes=%s\n' "$(awk '{ if (length > max) max=length } END { print max + 0 }' "$CASE_DIR/tmux.log")" + printf 'evaluated_payload_bytes=%s\n' "$(wc -c < "$CASE_DIR/evaluated" | tr -d ' ')" + printf 'evaluated_payload_non_x_bytes=%s\n' "$(tr -d 'x\n' < "$CASE_DIR/evaluated" | wc -c | tr -d ' ')" + } > "$FM_TEST_EVIDENCE_DIR/launch-retry-transcript.txt" + fi + pass "fm-spawn retries the recorded canonical-buffer truncation and stages only bounded lines" +} + +test_refuses_to_report_success_when_every_delivery_check_is_truncated() { + local id rec out rc + id='launch-refuse-z2' + rec=$(make_case refuse "$id") + read_case "$rec" + out=$(FM_FAKE_TRUNCATE_ATTEMPTS=3 FM_SPAWN_LAUNCH_DELIVERY_RETRIES=2 \ + run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$(long_raw_launch)") || rc=$? + [ "${rc:-0}" -ne 0 ] || fail "fm-spawn reported success after every staged launch was truncated" + assert_contains "$out" "launch command delivery could not be verified after 2 attempts" \ + "failed delivery did not name the bounded verification refusal" + assert_grep 'failed: launch command delivery could not be verified after 2 attempts' \ + "$HOME_DIR/state/$id.status" "failed delivery was not recorded for supervision" + [ ! -s "$CASE_DIR/evaluated" ] || fail "failed launch verification still evaluated the staged command" + if [ -n "${FM_TEST_EVIDENCE_DIR:-}" ]; then + mkdir -p "$FM_TEST_EVIDENCE_DIR" + { + printf '$ fm-spawn.sh %s <1,800-byte raw launch>\n' "$id" + printf '%s\n' "$out" + printf '\nPersisted supervision status:\n' + cat "$HOME_DIR/state/$id.status" + printf '\nObserved shell output across delivery attempts:\n' + cat "$CASE_DIR/screen-history" + printf '\nRefusal facts:\n' + printf 'attempts=%s\n' "$(cat "$CASE_DIR/attempts")" + printf 'evaluated_payload_bytes=%s\n' "$(wc -c < "$CASE_DIR/evaluated" | tr -d ' ')" + } > "$FM_TEST_EVIDENCE_DIR/launch-refusal-transcript.txt" + fi + pass "fm-spawn fails loudly when bounded launch verification never succeeds" +} + +test_long_task_id_keeps_verification_markers_on_one_pane_line() { + local id rec out rc + id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + rec=$(make_case long-id "$id") + read_case "$rec" + out=$(run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$(long_raw_launch)") + rc=$? + expect_code 0 "$rc" "64-character task ID should not wrap launch verification markers"$'\n'"$out" + assert_contains "$out" "spawned $id" "long task ID did not complete verified launch delivery" + pass "fm-spawn keeps verification markers bounded independently of task ID length" +} + +test_retries_the_recorded_truncation_signature_and_never_types_a_long_line +test_refuses_to_report_success_when_every_delivery_check_is_truncated +test_long_task_id_keeps_verification_markers_on_one_pane_line + +echo "# all fm-spawn-launch-delivery tests passed" diff --git a/tests/secondmate-helpers.sh b/tests/secondmate-helpers.sh index b80a432fcb9..355610bc455 100644 --- a/tests/secondmate-helpers.sh +++ b/tests/secondmate-helpers.sh @@ -24,10 +24,37 @@ make_fake_tmux() { #!/usr/bin/env bash set -u case "${1:-}" in - has-session|new-session|new-window|send-keys|kill-window) + has-session|new-session|new-window|kill-window) printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" exit 0 ;; + send-keys) + printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" + text=${4:-} + staged="$FM_FAKE_TMUX_CAPTURE.staged" + case "$text" in + *"__FM_SPAWN_READY_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$FM_FAKE_TMUX_CAPTURE" + ;; + "FM_SPAWN_LAUNCH=''" ) + : > "$staged" + ;; + FM_SPAWN_LAUNCH=*) + rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"") + printf '%s' "$rebuilt" > "$staged" + ;; + *"__FM_SPAWN_LAUNCH_OK_"*) + token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\([^']*\)'.*/\1/p") + [ -z "$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\n' "$token" > "$FM_FAKE_TMUX_CAPTURE" + ;; + 'eval "$FM_SPAWN_LAUNCH"') + cat "$staged" >> "$FM_FAKE_TMUX_LOG" + printf '\n' >> "$FM_FAKE_TMUX_LOG" + ;; + esac + exit 0 + ;; list-windows) if [ -n "${FM_FAKE_TMUX_WINDOW:-}" ]; then printf '%s\n' "$FM_FAKE_TMUX_WINDOW"