From ddf195003d8954d151eb3d6f0a16a8147b249064 Mon Sep 17 00:00:00 2001 From: Hyung Cho <41488000+hcho22@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:13:39 -0700 Subject: [PATCH 1/4] fix: target session not active window in fm-spawn new-window new-window -t "$SES" resolved to the session's active window and tried to create at that occupied index, failing "create window failed: index N in use" when low window indices were occupied. The trailing colon ("$SES:") forces session-only targeting so tmux picks the next free index. --- bin/fm-spawn.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index be42735..a3d6f41 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -327,7 +327,7 @@ if tmux list-windows -t "$SES" -F '#{window_name}' | grep -qx "$W"; then exit 1 fi -tmux new-window -d -t "$SES" -n "$W" -c "$PROJ_ABS" +tmux new-window -d -t "$SES:" -n "$W" -c "$PROJ_ABS" if [ "$KIND" != secondmate ]; then tmux send-keys -t "$T" 'treehouse get' Enter From 785bdb6abcbfb245ed23f8e062151e7ff9525ae0 Mon Sep 17 00:00:00 2001 From: Hyung Cho <41488000+hcho22@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:31:04 -0700 Subject: [PATCH 2/4] no-mistakes(document): document fm-spawn batch and window-index behavior tests in README --- README.md | 2 + tests/fm-spawn-window-index.test.sh | 91 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100755 tests/fm-spawn-window-index.test.sh diff --git a/README.md b/README.md index 6090a42..4951d80 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,8 @@ tests/fm-bootstrap.test.sh # bootstrap dependency and feature-pro tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests tests/fm-secondmate.test.sh # persistent secondmate routing, seeding, idle charter, backlog handoff, spawn, recovery, teardown, and FM_HOME tests tests/fm-teardown.test.sh # fm-teardown.sh safety and reminder checks: local-only fork-remote allow, truly-unpushed refuse, merged-to-main allow, no-mistakes regression, tasks-axi reminder, --force override +tests/fm-spawn-batch.test.sh # fm-spawn.sh batch dispatch argument routing: id=repo pair parsing, shared --scout, and partial-failure handling +tests/fm-spawn-window-index.test.sh # fm-spawn.sh new-window session-targeting fix: next-free-index placement and the occupied-index failure it avoids [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] [ "$(readlink .claude/skills)" = "../.agents/skills" ] FM_HEARTBEAT=2 FM_POLL=1 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints "heartbeat") diff --git a/tests/fm-spawn-window-index.test.sh b/tests/fm-spawn-window-index.test.sh new file mode 100755 index 0000000..b40c82d --- /dev/null +++ b/tests/fm-spawn-window-index.test.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Regression tests for the fm-spawn.sh window-targeting fix. +# +# fm-spawn creates each crewmate window with `tmux new-window`. The pre-fix command +# targeted the bare session name (`-t "$SES"`), which on some tmux versions resolves to +# the session's ACTIVE window and tries to create the new window at that already-occupied +# index, failing "create window failed: index N in use" when low indices are taken. +# The fix appends a colon (`-t "$SES:"`) to force session-only targeting so tmux always +# picks the next free index. +# +# These tests run entirely on a PRIVATE tmux socket (-L) with $TMUX unset, so they never +# touch any live firstmate session. They skip cleanly when tmux is unavailable. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SPAWN="$ROOT/bin/fm-spawn.sh" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +if ! command -v tmux >/dev/null 2>&1; then + pass "tmux unavailable; window-index behavior tests skipped" + exit 0 +fi + +SOCK="fm-spawn-winidx-$$" +unset TMUX # detach from any ambient tmux server so probes use only our private socket +cleanup() { tmux -L "$SOCK" kill-server 2>/dev/null || true; } +trap cleanup EXIT +tmux -L "$SOCK" start-server 2>/dev/null || true + +# Build the realistic firstmate-inside-tmux condition: a session whose low window +# indices (0,1,2) are occupied and whose ACTIVE window is a low occupied index. +build_session() { + local ses=$1 + tmux -L "$SOCK" kill-session -t "$ses" 2>/dev/null || true + tmux -L "$SOCK" set -g base-index 0 2>/dev/null || true # deterministic regardless of host tmux.conf + tmux -L "$SOCK" new-session -d -s "$ses" -n w0 + tmux -L "$SOCK" new-window -d -t "$ses:1" -n w1 + tmux -L "$SOCK" new-window -d -t "$ses:2" -n w2 + tmux -L "$SOCK" select-window -t "$ses:0" # active = occupied low index +} + +# 1. The source carries the session-targeting fix (guards the literal one-line change). +test_source_uses_session_target() { + grep -Fq 'tmux new-window -d -t "$SES:"' "$SPAWN" \ + || fail "fm-spawn.sh must target the session ('\$SES:'), not the bare session/active window" + if grep -E 'tmux new-window -d -t "\$SES"[^:]' "$SPAWN" >/dev/null 2>&1; then + fail "fm-spawn.sh still uses the bare-name new-window target" + fi + pass "fm-spawn.sh issues new-window against the session target (\$SES:)" +} + +# 2. The fixed form lands the crewmate window at the next free index and never errors, +# even with low indices occupied and the active window on a low occupied index. +test_session_target_picks_next_free() { + local ses=FIX out st idx + build_session "$ses" + # Exactly the command fm-spawn.sh issues, with the same flags. + out=$(tmux -L "$SOCK" new-window -d -t "$ses:" -n fm-crew-test -c "$ROOT" 2>&1) + st=$? + [ "$st" -eq 0 ] || fail "session-target new-window failed (exit $st): $out" + idx=$(tmux -L "$SOCK" list-windows -t "$ses" -F '#{window_index} #{window_name}' \ + | awk '$2=="fm-crew-test"{print $1}') + [ -n "$idx" ] || fail "crewmate window was not created" + [ "$idx" -ge 3 ] || fail "crewmate window landed at occupied index $idx instead of next free (>=3)" + pass "session-target form creates the crewmate window at the next free index ($idx)" +} + +# 3. The failure the fix avoids is real: targeting an occupied window index fails with the +# exact "index N in use" error from the bug report, which session-only targeting sidesteps. +test_occupied_index_target_is_rejected() { + local ses=BUG out st + build_session "$ses" + out=$(tmux -L "$SOCK" new-window -d -t "$ses:0" -n fm-crew-test -c "$ROOT" 2>&1) + st=$? + [ "$st" -ne 0 ] || fail "targeting occupied index 0 should fail, but it succeeded" + printf '%s\n' "$out" | grep -Fq 'index 0 in use' \ + || fail "expected 'index N in use' failure, got: $out" + pass "targeting an occupied window index fails 'index 0 in use' (the bug the fix avoids)" +} + +test_source_uses_session_target +test_session_target_picks_next_free +test_occupied_index_target_is_rejected From 1bc7da76e9607b0c356e75421557f6e01947f5ab Mon Sep 17 00:00:00 2001 From: Hyung Cho <41488000+hcho22@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:33:44 -0700 Subject: [PATCH 3/4] no-mistakes(lint): silence deliberate single-quote SC2016 in fm-spawn window-index test --- tests/fm-spawn-window-index.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fm-spawn-window-index.test.sh b/tests/fm-spawn-window-index.test.sh index b40c82d..73c8e9c 100755 --- a/tests/fm-spawn-window-index.test.sh +++ b/tests/fm-spawn-window-index.test.sh @@ -49,8 +49,10 @@ build_session() { # 1. The source carries the session-targeting fix (guards the literal one-line change). test_source_uses_session_target() { + # shellcheck disable=SC2016 # single quotes are deliberate: this greps fm-spawn.sh's literal source, not an expansion. grep -Fq 'tmux new-window -d -t "$SES:"' "$SPAWN" \ || fail "fm-spawn.sh must target the session ('\$SES:'), not the bare session/active window" + # shellcheck disable=SC2016 # single quotes are deliberate: this greps fm-spawn.sh's literal source, not an expansion. if grep -E 'tmux new-window -d -t "\$SES"[^:]' "$SPAWN" >/dev/null 2>&1; then fail "fm-spawn.sh still uses the bare-name new-window target" fi From 553efb6fb904f5d81c5a7a92af69a5ec7e769edb Mon Sep 17 00:00:00 2001 From: Hyung Cho <41488000+hcho22@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:36:22 -0700 Subject: [PATCH 4/4] no-mistakes(document): correct fm-spawn batch test description in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4951d80..d0d58a3 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ tests/fm-bootstrap.test.sh # bootstrap dependency and feature-pro tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests tests/fm-secondmate.test.sh # persistent secondmate routing, seeding, idle charter, backlog handoff, spawn, recovery, teardown, and FM_HOME tests tests/fm-teardown.test.sh # fm-teardown.sh safety and reminder checks: local-only fork-remote allow, truly-unpushed refuse, merged-to-main allow, no-mistakes regression, tasks-axi reminder, --force override -tests/fm-spawn-batch.test.sh # fm-spawn.sh batch dispatch argument routing: id=repo pair parsing, shared --scout, and partial-failure handling +tests/fm-spawn-batch.test.sh # fm-spawn.sh batch dispatch argument routing: id=repo pair parsing, batch-vs-single-task detection, partial-failure reporting, and FM_HOME/FM_PROJECTS path scoping tests/fm-spawn-window-index.test.sh # fm-spawn.sh new-window session-targeting fix: next-free-index placement and the occupied-index failure it avoids [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] [ "$(readlink .claude/skills)" = "../.agents/skills" ]