From e00cc405862fbefa8582e43c8e4b02d651c83807 Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Mon, 29 Jun 2026 10:12:21 +0200 Subject: [PATCH 1/2] fix(spawn): make tmux window handling robust under non-default config Two bugs surface under base-index 1 + automatic-rename on (reproduced on macOS tmux): 1. Window-creation collision. `new-window -t "$SES"` (no trailing colon) targets the session's active window index instead of appending; under base-index 1 with a window already at index 1, tmux fails with "create window failed: index 1 in use" and no crew window is created. Target "$SES:" so tmux appends at the next free index. 2. Lost window name -> worktree misread. With automatic-rename on, once `treehouse get` cd's the pane into the worktree, tmux renames the window away from fm-. The wait loop's `display-message -t "$SES:$W"` then can't find it by name and falls back to the active client's window, returning firstmate's own pane path (the primary checkout) as the supposed worktree -- so the turn-end hook and recorded worktree land in the primary. Capture the stable window id from `new-window -P -F '#{window_id}'`, disable automatic-rename/allow-rename on it so the name sticks, and target the wait-loop send-keys/display-message by that id. Add a deterministic tangle-guard test (recording fake tmux) pinning the append-form creation, name pinning, and id-based targeting. --- bin/fm-spawn.sh | 15 ++++-- tests/fm-tangle-guard.test.sh | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 38747d5c..a8b54d6e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -353,13 +353,22 @@ 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" +WID=$(tmux new-window -dP -F '#{window_id}' -t "$SES:" -n "$W" -c "$PROJ_ABS") +# Pin the window name. The captain's tmux may have automatic-rename/allow-rename on, +# which would rename the window away from fm- once treehouse cd's into the worktree, +# breaking firstmate's name-based targeting ($SES:$W). Disable both on this window. +tmux set-window-option -t "$WID" automatic-rename off 2>/dev/null || true +tmux set-window-option -t "$WID" allow-rename off 2>/dev/null || true if [ "$KIND" != secondmate ]; then - tmux send-keys -t "$T" 'treehouse get' Enter + tmux send-keys -t "$WID" 'treehouse get' Enter # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. + # Target the stable window id, not the name: if the name is ever lost (e.g. an + # automatic-rename slips through), display-message -t falls back to the + # active client's window, which would misread firstmate's OWN pane path as the + # worktree and tangle a hook into the primary checkout. The window id never lies. for _ in $(seq 1 60); do - p=$(tmux display-message -p -t "$T" '#{pane_current_path}' 2>/dev/null || true) + p=$(tmux display-message -p -t "$WID" '#{pane_current_path}' 2>/dev/null || true) if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ]; then WT="$p" break diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index bc70bd70..c5a7ff83 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -206,8 +206,96 @@ test_spawn_isolation_abort() { pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree" } +# --- GUARD 1c: fm-spawn tmux window construction ---------------------------- + +# The prevention guard also depends on fm-spawn building robust tmux commands +# under a non-default tmux config (base-index 1, automatic-rename on). A RECORDING +# fake tmux logs every invocation and returns a sentinel window id, so these +# assertions pin the command construction deterministically, with no live tmux: +# - window creation targets the session with a trailing colon (append form), so +# tmux appends at the next free index instead of the active window index, which +# collides under base-index 1; +# - the window id is captured (-P -F #{window_id}) and automatic-rename/allow-rename +# are disabled so the fm- name survives treehouse cd'ing into the worktree; +# - the treehouse-get send-keys and the worktree wait loop target that stable +# window id, never the (possibly-renamed) name - a lost name would let +# display-message fall back to the active client's window and misread firstmate's +# OWN pane as the worktree, tangling a hook into the primary checkout. +make_spawn_record_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +[ -n "${FM_TMUX_REC:-}" ] && printf 'tmux %s\n' "$*" >> "$FM_TMUX_REC" +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 ;; + new-window) printf '%s\n' "@spawnwid"; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|send-keys|set-window-option) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +run_spawn_record() { + local home=$1 id=$2 proj=$3 pane=$4 fakebin=$5 rec=$6 + mkdir -p "$home/data/$id" + printf 'brief\n' > "$home/data/$id/brief.md" + 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="$pane" TMUX="fake,1,0" \ + FM_TMUX_REC="$rec" \ + PATH="$fakebin:$PATH" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex 2>&1 +} + +test_spawn_tmux_window_construction() { + local home proj fakebin rec wt out status + home="$TMP_ROOT/spawn-rec-home" + mkdir -p "$home/data" + proj=$(make_repo "$TMP_ROOT/spawn-rec-proj") + fakebin=$(make_spawn_record_fakebin "$TMP_ROOT/spawn-rec-fake") + rec="$TMP_ROOT/spawn-rec.log" + : > "$rec" + wt="$TMP_ROOT/spawn-rec-wt" + git -C "$proj" worktree add -q --detach "$wt" >/dev/null 2>&1 + + out=$(run_spawn_record "$home" rec-win-gg7 "$proj" "$wt" "$fakebin" "$rec"); status=$? + expect_code 0 "$status" "spawn into a genuine worktree should succeed" + assert_contains "$out" "spawned rec-win-gg7" "recording spawn did not report success" + + # Bug 1 fix: append-form window creation (trailing colon on the session target). + assert_grep "new-window -dP -F #{window_id} -t firstmate: -n fm-rec-win-gg7" "$rec" \ + "new-window must append at the session (trailing colon) and capture the window id" + assert_no_grep "new-window -dP -F #{window_id} -t firstmate -n" "$rec" \ + "new-window must not target the bare session name (collides under base-index 1)" + + # Bug 2 fix (a): pin the window name against automatic-rename / allow-rename. + assert_grep "set-window-option -t @spawnwid automatic-rename off" "$rec" \ + "must disable automatic-rename on the spawned window" + assert_grep "set-window-option -t @spawnwid allow-rename off" "$rec" \ + "must disable allow-rename on the spawned window" + + # Bug 2 fix (b): treehouse-get and the worktree wait loop target the stable id. + assert_grep "send-keys -t @spawnwid treehouse get Enter" "$rec" \ + "treehouse get must be sent to the stable window id" + assert_grep "display-message -p -t @spawnwid #{pane_current_path}" "$rec" \ + "the worktree wait loop must query the stable window id, not the name" + + pass "fm-spawn: appends windows by session-colon, pins the name, and targets the window id" +} + test_lib_classification test_guard_banner test_bootstrap_line test_brief_assertion_precedes_branch test_spawn_isolation_abort +test_spawn_tmux_window_construction From 4a78fc70a8e5f97d7783310f4a7a7eacc8d46caa Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Mon, 29 Jun 2026 11:18:06 +0200 Subject: [PATCH 2/2] no-mistakes(document): note fm-spawn tmux window construction test in CONTRIBUTING coverage list --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e1675f6..3b820bfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,7 +72,7 @@ tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of th tests/fm-bootstrap.test.sh # bootstrap dependency and feature-probe tests tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, and bootstrap relay tests/fm-x-mode.test.sh # X-mode poll, inbox context round-trip, reply threading, dismiss, dry-run preview, and .env-presence activation tests -tests/fm-tangle-guard.test.sh # primary-checkout tangle detection and spawn/brief isolation tests +tests/fm-tangle-guard.test.sh # primary-checkout tangle detection, spawn/brief isolation, and fm-spawn tmux window construction (append-form creation, name pinning, window-id targeting) tests tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch, bootstrap nudge gating, and spawn hook tests