Skip to content

fix(spawn): make tmux window handling robust under non-default config#134

Open
Deeds67 wants to merge 2 commits into
kunchenguid:mainfrom
Deeds67:fm/spawn-tmux-fix-r5
Open

fix(spawn): make tmux window handling robust under non-default config#134
Deeds67 wants to merge 2 commits into
kunchenguid:mainfrom
Deeds67:fm/spawn-tmux-fix-r5

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jun 29, 2026

Copy link
Copy Markdown

Intent

The developer was working on the firstmate codebase as an ordinary software project, tasking a coding agent with applying a small robustness fix to bin/fm-spawn.sh's tmux window handling to address two empirically-reproduced bugs that surface under a non-default tmux config (base-index 1 and automatic-rename on): a window-creation collision (fixed by targeting the session with a trailing colon so tmux appends at the next free index) and a dangerous lost-window-name issue where a renamed window caused the wait-loop to misread firstmate's own primary checkout as the worktree (fixed by capturing the stable window id, disabling automatic-rename/allow-rename, and targeting by id). They required the change be confined to that one script plus an optional deterministic test, that bash -n and shellcheck pass, and that the behavior test suite (especially the spawn/tangle tests) pass, with the work shipping through the no-mistakes contribution pipeline. After the code was committed and verified locally, the pipeline was blocked by a no-mistakes gate transport error (invalid gate path: '.'); the developer fixed gate infrastructure on their end (restarting the daemon and clearing the stale ref), and when that did not resolve it, told the agent to stand by while they worked the deeper no-mistakes transport/path issue from the firstmate side, instructing it not to retry the gate or change anything.

What Changed

  • bin/fm-spawn.sh: create the task window by targeting the session with a trailing colon so tmux appends at the next free index instead of colliding when base-index is non-zero, and capture the resulting stable window id from new-window -P.
  • bin/fm-spawn.sh: disable automatic-rename/allow-rename on the new window and target the worktree wait loop and treehouse get send-keys by the captured window id, so a window rename can no longer make the wait loop misread firstmate's own primary checkout as the worktree.
  • Added test_spawn_tmux_window_construction to tests/fm-tangle-guard.test.sh covering the window-construction behavior, and listed it in the CONTRIBUTING.md coverage list.

Risk Assessment

✅ Low: A small, well-bounded robustness fix confined to one script's tmux handling plus a hermetic deterministic test; the only finding is a low-practical-risk consistency gap where the launch and persisted meta target still use name-based addressing the fix otherwise hardened against.

Testing

Baseline (the full tmux-gated e2e suite) already passed; on top of that I confirmed the change's added deterministic test passes and then demonstrated the intent end-to-end on a real tmux server configured with the exact bug-triggering options (base-index 1, automatic-rename on, allow-rename on). Running the real fm-spawn.sh there, it spawns successfully, resolves the correct isolated worktree, and the window retains its fm-<id> name despite the rename config; a side-by-side run against the actual pre-fix base script shows that code deterministically failing under the same conditions, and an instrumented copy proves the most dangerous mode — the old name-based query silently returning firstmate's own primary checkout as the worktree and slipping past the isolation guard. Bug 1's hard collision does not surface as an error on the installed tmux 3.6b for a bare session name (it does for an explicitly occupied index), but the colon-append form the fix adopts is the canonical version-independent append and is pinned by the new test — non-actionable. This is a shell/CLI/tmux change with no rendered UI surface, so evidence is CLI transcripts rather than screenshots. Overall: all tests pass and the user intent is demonstrated working end-to-end.

Evidence: Focused real-tmux mechanism demo (annotated before/after for both bugs)

BUG 1 — old form 'new-window -t firstmate:1' => create window failed: index 1 in use; new form 'new-window -t firstmate:' => created @1 at next free index, and CAPTURED the id BUG 2 (name lost to allow-rename): >>> OLD code by NAME (firstmate:fm-job) -> /private/tmp/.../primary *** DANGER: firstmate's PRIMARY checkout, NOT the worktree *** >>> NEW code by id (@1) -> /private/tmp/.../worktree CORRECT: the real worktree BUG 2 fix (a) — pinned window after same rename trigger: name stayed 'fm-pinned'; firstmate:fm-pinned -> /private/tmp/.../worktree

================================================================
BUG 1 — window creation under base-index 1
================================================================
session 'firstmate' created; first window sits at index 1 (base-index 1):
   1: home

OLD form targets an explicit occupied index -> tmux refuses (collision):
   new-window -t firstmate:1  => create window failed: index 1 in use
   (rc=1, refused)
NEW form targets the session with a trailing colon -> tmux appends at next free index:
   new-window -t 'firstmate:'  => created @1 at next free index, and CAPTURED the id
   1: home (@0)
   2: fm-job (@1)

================================================================
BUG 2 — window name lost to automatic-rename/allow-rename
================================================================
PRIMARY (firstmate's own checkout) = /private/tmp/fm-mech.tDnmqD/primary
WORKTREE (crewmate's isolated wt)   = /private/tmp/fm-mech.tDnmqD/worktree

The worktree subshell cd's in and its prompt emits a title escape; with
allow-rename on, tmux renames the window away from 'fm-job' instantly:
   1: ..DnmqD/primary (@0) cwd=/private/tmp/fm-mech.tDnmqD/primary
   2: ..nmqD/worktree (@1) cwd=/private/tmp/fm-mech.tDnmqD/worktree

>>> OLD code queried the window by NAME (firstmate:fm-job):
    -> returns: /private/tmp/fm-mech.tDnmqD/primary
    -> *** DANGER: that is firstmate's PRIMARY checkout, NOT the worktree ***

>>> NEW code queries the window by stable id (@1):
    -> returns: /private/tmp/fm-mech.tDnmqD/worktree
    -> CORRECT: the real worktree

================================================================
BUG 2 fix part (a) — pinning the name blocks the rename entirely
================================================================
created+pinned @2 (name fm-pinned); now hit it with the SAME rename trigger:
   name after the rename attempt: fm-pinned  (stayed fm-pinned)
   name-based target now still resolves correctly:
      firstmate:fm-pinned -> /private/tmp/fm-mech.tDnmqD/worktree
Evidence: End-to-end: real fm-spawn.sh under bug config (PASS)

spawned e2e-fix-zz9 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-e2e-fix-zz9 worktree=/private/tmp/fm-e2e.../wt meta: window=firstmate:fm-e2e-fix-zz9 / worktree=/private/tmp/fm-e2e.../wt live windows: 2: fm-e2e-fix-zz9 (@1) cwd=/private/tmp/fm-e2e.../wt PASS: resolved the WORKTREE, not the primary checkout PASS: window kept its name 'fm-e2e-fix-zz9' (automatic-rename pinned off) === END-TO-END RESULT: PASS ===

=== inputs ===
project (primary checkout) = /private/tmp/fm-e2e.6bENaQ/proj
worktree (isolated)        = /private/tmp/fm-e2e.6bENaQ/wt
tmux config: base-index 1, automatic-rename on, allow-rename on

=== running REAL bin/fm-spawn.sh (TMUX unset -> creates 'firstmate' session on private socket) ===
warn: no registry at /tmp/fm-e2e.6bENaQ/home/data/projects.md; defaulting proj to no-mistakes off
spawned e2e-fix-zz9 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-e2e-fix-zz9 worktree=/private/tmp/fm-e2e.6bENaQ/wt
fm-spawn exit code = 0

=== state/e2e-fix-zz9.meta written by fm-spawn ===
window=firstmate:fm-e2e-fix-zz9
worktree=/private/tmp/fm-e2e.6bENaQ/wt
project=/private/tmp/fm-e2e.6bENaQ/proj
harness=codex
kind=ship
mode=no-mistakes
yolo=off

=== live tmux windows on the private server (name pinned despite automatic-rename) ===
  1: bash (@0)  cwd=/Users/pierre/.no-mistakes/worktrees/115f5e328d0c/01KW98ENA4KVE77QNFD2YZKQS9
  2: fm-e2e-fix-zz9 (@1)  cwd=/private/tmp/fm-e2e.6bENaQ/wt

PASS: fm-spawn resolved the WORKTREE (/private/tmp/fm-e2e.6bENaQ/wt), not the primary checkout
PASS: spawned window kept its name 'fm-e2e-fix-zz9' (automatic-rename pinned off)

=== END-TO-END RESULT: PASS ===
Evidence: Before/after vs pre-fix base script (base fails 3/3, fix correct 3/3)

BEFORE (base 81c94db): BASE#1 exit=1 aborted; BASE#2 exit=1 aborted; BASE#3 exit=1 aborted AFTER (target e00cc40): FIXED#1 CORRECT (real worktree); FIXED#2 CORRECT; FIXED#3 CORRECT

############ BEFORE — base 81c94db (name-based targeting), allow-rename trigger ############
[BASE#1] exit=1
[BASE#1] aborted before recording a worktree. message:
      
        (real worktree=/private/tmp/fm-ba.sbm165/wt ; primary=/private/tmp/fm-ba.sbm165/primary ; project=/private/tmp/fm-ba.sbm165/proj)

[BASE#2] exit=1
[BASE#2] aborted before recording a worktree. message:
      
        (real worktree=/private/tmp/fm-ba.bqqBYu/wt ; primary=/private/tmp/fm-ba.bqqBYu/primary ; project=/private/tmp/fm-ba.bqqBYu/proj)

[BASE#3] exit=1
[BASE#3] aborted before recording a worktree. message:
      
        (real worktree=/private/tmp/fm-ba.fSoH8f/wt ; primary=/private/tmp/fm-ba.fSoH8f/primary ; project=/private/tmp/fm-ba.fSoH8f/proj)

############ AFTER — target e00cc40 (window-id + name pinned) ############
[FIXED#1] exit=0
[FIXED#1] CORRECT: resolved the real worktree (/private/tmp/fm-ba.LLcFFN/wt)
        (real worktree=/private/tmp/fm-ba.LLcFFN/wt ; primary=/private/tmp/fm-ba.LLcFFN/primary ; project=/private/tmp/fm-ba.LLcFFN/proj)

[FIXED#2] exit=0
[FIXED#2] CORRECT: resolved the real worktree (/private/tmp/fm-ba.VjTgOo/wt)
        (real worktree=/private/tmp/fm-ba.VjTgOo/wt ; primary=/private/tmp/fm-ba.VjTgOo/primary ; project=/private/tmp/fm-ba.VjTgOo/proj)

[FIXED#3] exit=0
[FIXED#3] CORRECT: resolved the real worktree (/private/tmp/fm-ba.1NZBC8/wt)
        (real worktree=/private/tmp/fm-ba.1NZBC8/wt ; primary=/private/tmp/fm-ba.1NZBC8/primary ; project=/private/tmp/fm-ba.1NZBC8/proj)
Evidence: Instrumented base proves primary-checkout misread

DEBUG[base]: wait-loop resolved WT=/private/tmp/.../primary (PROJ_ABS=/private/tmp/.../proj) spawned ba-task-qq1 ... worktree=/private/tmp/.../primary <-- pre-fix records firstmate's OWN checkout as the worktree; isolation guard did not catch it

Captures: the PRE-FIX (base) fm-spawn under base-index 1 + automatic-rename/allow-rename.
The wait-loop misreads firstmate's OWN primary checkout as the worktree and the
isolation guard does NOT catch it (the primary is a distinct repo). Result: meta records
worktree=<primary>. (Launch send-keys short-circuited in this analysis copy so the
misread is visible; in the unmodified base the final launch then errors on the lost name.)
================================================================
PRIMARY=/private/tmp/fm-ba.8XLVDL/primary
WT=/private/tmp/fm-ba.8XLVDL/wt
PROJ=/private/tmp/fm-ba.8XLVDL/proj
---- fm-spawn output ----
DEBUG[base]: wait-loop resolved WT=/private/tmp/fm-ba.8XLVDL/primary (PROJ_ABS=/private/tmp/fm-ba.8XLVDL/proj)
warn: no registry at /tmp/fm-ba.8XLVDL/home/data/projects.md; defaulting proj to no-mistakes off
DEBUG[base]: would launch into window firstmate:fm-ba-task-qq1 (name-based)
spawned ba-task-qq1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-ba-task-qq1 worktree=/private/tmp/fm-ba.8XLVDL/primary
---- exit=0 ----

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 warning
  • ⚠️ bin/fm-spawn.sh:502 - The fix hardens the worktree wait loop and the treehouse get send-keys to use the stable $WID, with a comment warning that a lost window name makes display-message -t &lt;bad-name&gt; fall back to the active client's window. But the two highest-stakes operations still use the name-based $T ($SES:$W): the agent launch at lines 502/504 (tmux send-keys -t &#34;$T&#34; -l &#34;$LAUNCH&#34; then Enter) and the persisted supervision target at line 479 (window=$T in meta, consumed by fm-peek/fm-send/fm-teardown/fm-watch/fm-ff-lib as a tmux target for the whole task lifetime). In the exact scenario the fix defends against — the name lost before the rename-disable settles (e.g. an allow-rename escape sequence in the create→disable gap) — the launch keys could be sent to the wrong/active pane (potentially firstmate's own session) and the recorded window= target would be broken for all later supervision. In practice the risk is low: new-window -n auto-disables automatic-rename for the window, allow-rename off is set before treehouse/agent run, so by launch time the name is well-pinned and the residual race is a microsecond window with a bare shell. Recommend using $WID for the launch send-keys and the persisted meta target for full consistency with the fix's own rationale; note this changes the documented window=&lt;session:window&gt; meta/output format (line 30) to a @id, which is a valid tmux target everywhere but is the reason this warrants author sign-off rather than a blind change.
✅ **Test** - passed

✅ No issues found.

  • command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"
  • bash tests/fm-tangle-guard.test.sh — full file incl. the new test_spawn_tmux_window_construction; all 6 cases ok
  • Full behavior suite spot-checked: ran each tests/*.test.sh; every file that completed reported its final ok/all ... passed line (matches the green baseline run)
  • Real-tmux mechanism demo on an isolated server with base-index 1; automatic-rename on; allow-rename on: old form new-window -t firstmate:1 collides (index 1 in use); new form new-window -dP -F &#39;#{window_id}&#39; -t &#39;firstmate:&#39; appends and captures the id
  • Real-tmux Bug-2 reproduction: after a worktree subshell emits a window-rename escape, display-message -p -t firstmate:fm-job silently returns firstmate's PRIMARY checkout, while display-message -p -t @&lt;id&gt; returns the real worktree; set-window-option automatic-rename/allow-rename off keeps the name pinned
  • End-to-end with the REAL bin/fm-spawn.sh under the bug config (fake treehouse get cd'ing into a genuine git worktree, fake codex): exit 0, worktree= resolves to the worktree, meta correct, window keeps name fm-&lt;id&gt; (e2e-spawn-nondefault-tmux.sh)
  • Before/after: base fm-spawn.sh from 81c94db (staged with current libs) vs the fixed worktree script, 3 runs each — base fails 3/3, fixed resolves the real worktree 3/3 (e2e-before-after.sh)
  • Instrumented analysis copy of the base script: proves the pre-fix wait-loop resolves WT=&lt;primary checkout&gt; and records worktree=&lt;primary&gt;, with the isolation guard not catching it
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Deeds67 added 2 commits June 29, 2026 10:12
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-<id>. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant