Skip to content

fix(spawn): skip treehouse get's transient cwd in worktree-discovery poll#648

Open
psacc wants to merge 3 commits into
kunchenguid:mainfrom
psacc:fm/herdr-upstream-q6
Open

fix(spawn): skip treehouse get's transient cwd in worktree-discovery poll#648
psacc wants to merge 3 commits into
kunchenguid:mainfrom
psacc:fm/herdr-upstream-q6

Conversation

@psacc

@psacc psacc commented Jul 16, 2026

Copy link
Copy Markdown

Intent

Contribute a single, self-contained bug fix to the upstream firstmate repo (kunchenguid/firstmate) THROUGH this pipeline, so the PR carries the no-mistakes signature that upstream's "PR must be raised via no-mistakes" CI gate requires. This supersedes the existing hand-made PR #480 (#480), which is permanently blocked by that gate; the PR body should say "Supersedes #480" and reuse its problem/root-cause writeup.

The change is a cherry-pick of commit 2a5fa59, "fix(spawn): skip treehouse get's transient cwd in worktree-discovery poll". It repairs a herdr-backend single-slot wedge. Root cause: treehouse get transits intermediate cwds while it sets a worktree up (momentarily / and then the treehouse root) before it creates and enters the pooled worktree, and herdr's foreground_cwd exposes those transients (tmux's pane_current_path reports the settled cwd and never surfaced them, which is why only herdr wedged). bin/fm-spawn.sh's worktree-discovery poll previously accepted the FIRST cwd that merely differed from the project dir, so it captured /, handed that transient to validate_spawn_worktree, failed the isolation gate, and aborted the spawn, while the still-initializing treehouse get completed and held the pool's only slot forever with no crewmate attached (so the next spawn also failed). The fix gates the poll on a new non-fatal is_isolated_worktree predicate (a real git-worktree root, physically distinct from the primary checkout, the same rule validate_spawn_worktree now delegates to), so it skips / and the treehouse root and keeps polling until the pane settles in the real worktree. It is a no-op for tmux, whose first observed non-project cwd already is that worktree.

Deliberate decisions a reviewer should not flag as mistakes:

  • The branch was created OFF upstream/main (c115561) as a single commit carrying ONLY this fix - deliberately not off the fork's default branch, so the PR contains the fix alone and no fork merge history.
  • The PR must target UPSTREAM (base kunchenguid/firstmate main) via fork routing: the branch is pushed to the psacc/firstmate fork and the PR is opened against kunchenguid. This is intentional, not a misconfiguration.
  • Files touched are exactly: bin/fm-spawn.sh, docs/herdr-backend.md, tests/fm-backend.test.sh, tests/fm-tangle-guard.test.sh. Nothing else should ride along.
  • The reproduced bug is covered by a new regression test, tests/fm-backend.test.sh:test_spawn_worktree_poll_skips_treehouse_get_transient_cwd, which drives the real bin/fm-spawn.sh against a fake pane returning the project dir, then a transient /, then the real worktree.

Known environmental note (not a regression from this change): a pre-existing, unrelated test - "old fm-teardown.sh (scout, report present) should succeed" in tests/fm-backend.test.sh - fails when the tasks-axi binary is not on PATH, because bin/fm-decision-hold.sh hard-refuses without a compatible tasks-axi. That test is present verbatim on upstream/main and is untouched by this change; any failure there is a missing-tool precondition in the environment, not caused by this fix.

What Changed

  • bin/fm-spawn.sh's worktree-discovery poll no longer accepts the first cwd that merely differs from the project dir. It now gates on a new non-fatal is_isolated_worktree predicate (a real git-worktree root physically distinct from the primary checkout — the same rule validate_spawn_worktree now delegates to), so transient cwds that treehouse get transits while setting up a pooled worktree (momentarily /, then the treehouse root) are skipped and polling continues until the pane settles in the real worktree. This repairs a herdr-backend wedge where the poll captured /, failed the isolation gate, aborted the spawn, and left the pool's only slot held with no crewmate attached; tmux is unaffected since its first observed non-project cwd is already the settled worktree. The poll budget is configurable via FM_SPAWN_WORKTREE_POLL_TRIES, documented in docs/configuration.md and docs/herdr-backend.md.
  • Added a regression test (tests/fm-backend.test.sh:test_spawn_worktree_poll_skips_treehouse_get_transient_cwd) driving the real bin/fm-spawn.sh against a fake pane that returns the project dir, then a transient /, then the real worktree; the pre-fix script reproduces the exact live failure.
  • Test-suite hardening that surfaced during validation: teardown conformance tests now stub tasks-axi via a shared helper in tests/lib.sh (so they no longer require a globally installed binary), and watcher timing races in tests/fm-watcher-lock.test.sh / bin/fm-watch-checkpoint.sh were fixed.

Supersedes #480, which is blocked by upstream's raised-via-no-mistakes CI gate.

Risk Assessment

✅ Low: A single, well-bounded shell fix whose new predicate exactly mirrors the pre-existing fatal isolation check, with a dedicated regression test, a documented failure-mode writeup, and verified no adverse impact on any other test suite driving fm-spawn.sh.

Testing

Ran the affected suites at the target commit (all green, including the previously-failing teardown tests now hermetic via the tasks-axi stub on a machine without tasks-axi), then demonstrated the user intent end-to-end by swapping in the pre-fix bin/fm-spawn.sh and showing the new regression test reproduces the exact herdr wedge failure ("resolved '/'") that the fix eliminates; no visual evidence applies since this is a CLI/shell-script change with a transcript artifact instead.

Evidence: Repro transcript: wedge fails pre-fix, passes post-fix

PRE-FIX (a5a3652's bin/fm-spawn.sh): not ok - fm-spawn.sh should skip treehouse get's transient '/' cwd and settle on the real worktree error: treehouse get did not yield an isolated worktree (resolved '/'; worktree root 'none'; ...) — the exact live herdr single-slot wedge FIXED (a9f10f8): ok - fm-spawn.sh: the worktree-discovery poll skips treehouse get's transient intermediate cwds (/ , treehouse root) and resolves the real isolated worktree

# Evidence: herdr single-slot wedge fix (worktree-discovery poll skips treehouse get's transient cwd)

Repo: firstmate  branch fm/herdr-upstream-q6  (base a5d3652, target a9f10f8)
Environment: macOS, tmux 3.7b, tasks-axi NOT installed (stubbed hermetically by tests/lib.sh fm_fake_compatible_tasks_axi)

## 1. Regression test vs PRE-FIX bin/fm-spawn.sh (git show a5d3652:bin/fm-spawn.sh swapped in)
The test drives the real bin/fm-spawn.sh against a fake pane whose successive cwd polls return:
  project dir -> transient '/' -> real isolated worktree  (exactly what herdr's foreground_cwd exposes during 'treehouse get')

ok - fm-peek.sh: capture-pane invocation and output are byte-identical old vs new
ok - fm-spawn.sh: a project reached through a symlinked prefix (e.g. macOS /tmp -> /private/tmp) does not trip the isolation guard's false refusal
not ok - fm-spawn.sh should skip treehouse get's transient '/' cwd and settle on the real worktree
error: treehouse get did not yield an isolated worktree (resolved '/'; worktree root 'none'; primary '/var/folders/rb/gxcfwww97m97q62r6crm47580000gp/T/fm-backend-tests.fP2sBd/transient-proj'); refusing to launch to avoid tangling the primary checkout. Inspect target firstmate:fm-spawntransient: expected exit 0, got 1

^ pre-fix poll accepted the FIRST non-project cwd ('/'), failed the isolation gate, aborted the spawn -> the herdr single-slot wedge. Suite exit code: 1

## 2. Same regression test vs the FIXED bin/fm-spawn.sh (target a9f10f8)
ok - fm-spawn.sh: the worktree-discovery poll skips treehouse get's transient intermediate cwds (/ , treehouse root) and resolves the real isolated worktree

## 3. Full affected suites at target commit (all pass, rc=0)
tests/fm-backend.test.sh          ok (incl. new regression test + teardown conformance with tasks-axi stub)
tests/fm-tangle-guard.test.sh     ok
tests/fm-backend-orca.test.sh     ok
tests/fm-backend-zellij.test.sh   ok
tests/fm-watch-checkpoint.test.sh ok
tests/fm-watcher-lock.test.sh     ok
- Outcome: 🔧 1 issue found → auto-fixed ✅ across 2 runs (1h40m8s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ℹ️ bin/fm-spawn.sh:894 - Genuine-tangle refusal latency increased from ~1s to the full poll budget (60s): the poll no longer accepts the first non-project cwd, so a pane permanently settled in a non-isolated path (e.g. inside the primary checkout) only gets validate_spawn_worktree's precise refusal after all poll_tries elapse via the moved_to fallback. The inline comment documents this as a deliberate tradeoff (cannot distinguish a settling transient from a permanent tangle in one read), so no action needed — just an operational latency note.
  • ℹ️ bin/fm-spawn.sh:881 - FM_SPAWN_WORKTREE_POLL_TRIES validation (case "$poll_tries" in ''|[!0-9]|0) misses multi-digit zero values like "00"/"000": with GNU seq, seq 1 00 emits nothing so the poll loop never runs and the spawn immediately fails with "did not enter a worktree within 00s" (BSD seq would instead count down 1,0 and poll twice). It's an internal test-only knob, so impact is negligible; adding [ "$poll_tries" -gt 0 ] 2>/dev/null || poll_tries=60 after the case would close the gap.
🔧 **Test** - 1 issue found → auto-fixed ✅
  • 🚨 tests failed with exit code 1
  • 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"

🔧 Fix: stub tasks-axi in teardown tests; fix watcher timing races
✅ Re-checked - no issues remain.

  • 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"
  • Baseline: configured full-suite command (for t in tests/*.test.sh; do bash "$t"; done) already ran green before this round
  • bash tests/fm-backend.test.sh at target — all ok, including new test_spawn_worktree_poll_skips_treehouse_get_transient_cwd and the teardown conformance tests that previously needed a globally installed tasks-axi (not present on this machine; stub exercised)
  • bash tests/fm-tangle-guard.test.sh, tests/fm-backend-orca.test.sh, tests/fm-backend-zellij.test.sh, tests/fm-watch-checkpoint.test.sh, tests/fm-watcher-lock.test.sh — all pass at target
  • Pre-fix reproduction: swapped in git show a5d3652:bin/fm-spawn.sh, re-ran the regression test — it fails with the exact live-bug error treehouse get did not yield an isolated worktree (resolved '/'); restored the fixed script and it passes (worktree left clean)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

psacc and others added 3 commits July 16, 2026 18:30
…poll

The worktree-discovery poll accepted the first non-project cwd, but
treehouse get transits / and ~/.treehouse before it settles in the pooled
worktree. herdr's foreground_cwd exposes those transients (tmux's
pane_current_path reports the settled shell cwd and never did), so a herdr
spawn resolved WT to / , failed the isolation gate, and aborted - orphaning
the still-initializing treehouse get, which then held the pool's only slot
forever with no crewmate (the single-slot wedge).

Gate the poll on a new non-fatal is_isolated_worktree predicate (also the
one validate_spawn_worktree now delegates to) so it skips the transients
until the pane settles in a real worktree. Remember the last non-worktree
cwd so a genuine tangle still gets the precise resolved-path refusal instead
of a vague timeout, and make the poll budget configurable. No-op for tmux.

Regression test drives real fm-spawn against a pane that reports / then the
worktree; reproduces the exact 'resolved /' failure pre-fix.
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