From e35fff8db9a8662b276ffad940a72a610f3b78be Mon Sep 17 00:00:00 2001 From: Maxime Bourmaud Date: Fri, 3 Jul 2026 14:09:12 +0200 Subject: [PATCH 1/4] fix(spawn): only capture a treehouse-worktree pane path as the lease The worktree wait loop accepted the first pane cwd that differed from the project dir, so shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup - itself a git repo, so the post-loop isolation guard would fail the spawn rather than wait) could be captured. Require the captured path to be an existing git worktree under the treehouse root, so the loop keeps polling for the genuine lease; the isolation guard remains the backstop. --- bin/fm-spawn.sh | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c4352a6..0228e187 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -415,6 +415,22 @@ path_is_ancestor_of() { return 1 } +# A captured pane path is the treehouse lease only when it is an existing +# directory UNDER the treehouse root AND a git worktree. The wait loop below +# accepts the first pane cwd that differs from the project dir, but shell-init +# noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup - itself a git repo) +# can appear before `treehouse get` lands; the treehouse-root containment is what +# distinguishes the real lease from such noise, so the loop keeps polling for the +# genuine worktree instead of recording the noise. The isolation guard after the +# loop is the backstop; this keeps the loop from ever selecting the wrong path. +is_treehouse_worktree() { + local path=$1 root=$2 + [ -d "$path" ] || return 1 + path_is_ancestor_of "$root" "$path" || return 1 + [ "$(git -C "$path" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ] || return 1 + return 0 +} + validate_firstmate_home_for_spawn() { local id=$1 home=$2 abs_home abs_active_home abs_root marker_id abs_home=$(resolved_existing_dir "$home") || return 1 @@ -634,10 +650,18 @@ spawn_send_key() { # if [ "$KIND" != secondmate ]; then spawn_send_text_line "$T" 'treehouse get' - # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. + # The lease path lives under the treehouse root; default to ~/.treehouse but + # honor an explicit override for non-default treehouse roots. + TH_ROOT="${FM_TREEHOUSE_ROOT:-$HOME/.treehouse}" + + # Wait for the treehouse subshell: the pane's cwd moves from the project to the + # worktree. Only accept a captured path that is genuinely a git worktree under + # the treehouse root - a bare "differs from the project dir" check once recorded + # shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup) that + # appears before `treehouse get` lands. for _ in $(seq 1 60); do p=$(spawn_current_path "$T" || true) - if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ]; then + if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ] && is_treehouse_worktree "$p" "$TH_ROOT"; then WT="$p" break fi From c26ef6af7d889a7787235b75d087e3e5548000e7 Mon Sep 17 00:00:00 2001 From: Maxime Bourmaud Date: Fri, 3 Jul 2026 14:17:53 +0200 Subject: [PATCH 2/4] no-mistakes(review): replace treehouse-root check with repo-identity worktree check --- bin/fm-spawn.sh | 64 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 0228e187..f9d77114 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -415,19 +415,42 @@ path_is_ancestor_of() { return 1 } -# A captured pane path is the treehouse lease only when it is an existing -# directory UNDER the treehouse root AND a git worktree. The wait loop below -# accepts the first pane cwd that differs from the project dir, but shell-init -# noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup - itself a git repo) -# can appear before `treehouse get` lands; the treehouse-root containment is what -# distinguishes the real lease from such noise, so the loop keeps polling for the -# genuine worktree instead of recording the noise. The isolation guard after the -# loop is the backstop; this keeps the loop from ever selecting the wrong path. -is_treehouse_worktree() { - local path=$1 root=$2 - [ -d "$path" ] || return 1 - path_is_ancestor_of "$root" "$path" || return 1 - [ "$(git -C "$path" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ] || return 1 +# Resolve a git worktree's common dir to an absolute real path. `git rev-parse +# --git-common-dir` may return a path relative to the worktree, so anchor a +# relative result to the worktree before canonicalizing with pwd -P; this lets +# two worktrees of the same repository be compared regardless of symlinked paths. +git_common_dir_real() { + local dir=$1 common + common=$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null) || return 1 + [ -n "$common" ] || return 1 + case "$common" in + /*) ;; + *) common="$dir/$common" ;; + esac + [ -d "$common" ] || return 1 + (cd "$common" && pwd -P) +} + +# A captured pane path is the genuine treehouse lease only when it is a worktree +# of the SAME repository as the project, resolved distinctly from it. The wait +# loop below accepts the first pane cwd that differs from the project dir, but +# shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup - itself +# a git repo, but a DIFFERENT repository) can appear before `treehouse get` +# lands. Comparing canonical git common dirs distinguishes the real lease from +# such noise no matter where treehouse.toml places the pool root, and resolving +# every path with pwd -P keeps a symlinked $HOME from breaking the match. The +# isolation guard after the loop is the backstop; this keeps the loop from ever +# selecting the wrong path. +is_project_worktree() { + local candidate=$1 proj_abs=$2 cand_real proj_real cand_common proj_common + [ -d "$candidate" ] || return 1 + [ "$(git -C "$candidate" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ] || return 1 + cand_common=$(git_common_dir_real "$candidate") || return 1 + proj_common=$(git_common_dir_real "$proj_abs") || return 1 + [ "$cand_common" = "$proj_common" ] || return 1 + cand_real=$(cd "$candidate" 2>/dev/null && pwd -P) || return 1 + proj_real=$(cd "$proj_abs" 2>/dev/null && pwd -P) || return 1 + [ "$cand_real" != "$proj_real" ] || return 1 return 0 } @@ -650,18 +673,15 @@ spawn_send_key() { # if [ "$KIND" != secondmate ]; then spawn_send_text_line "$T" 'treehouse get' - # The lease path lives under the treehouse root; default to ~/.treehouse but - # honor an explicit override for non-default treehouse roots. - TH_ROOT="${FM_TREEHOUSE_ROOT:-$HOME/.treehouse}" - # Wait for the treehouse subshell: the pane's cwd moves from the project to the - # worktree. Only accept a captured path that is genuinely a git worktree under - # the treehouse root - a bare "differs from the project dir" check once recorded - # shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup) that - # appears before `treehouse get` lands. + # worktree. Only accept a captured path that is genuinely a worktree of the SAME + # repository as the project - a bare "differs from the project dir" check once + # recorded shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup) + # that appears before `treehouse get` lands. This needs no knowledge of the + # treehouse pool root, so it works wherever treehouse.toml places it. for _ in $(seq 1 60); do p=$(spawn_current_path "$T" || true) - if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ] && is_treehouse_worktree "$p" "$TH_ROOT"; then + if [ -n "$p" ] && is_project_worktree "$p" "$PROJ_ABS"; then WT="$p" break fi From 2566c349736a8a32f7dcc8c5994b53f5212ca707 Mon Sep 17 00:00:00 2001 From: Maxime Bourmaud Date: Fri, 3 Jul 2026 14:39:44 +0200 Subject: [PATCH 3/4] no-mistakes(test): guard bash 3.2 empty array in pr-merge; fix stale spawn worktree test --- bin/fm-pr-merge.sh | 2 +- bin/fm-spawn.sh | 5 +++-- tests/fm-tangle-guard.test.sh | 6 +++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 8ffb32cc..13e48230 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -87,4 +87,4 @@ if ! caller_has_merge_method "$@"; then merge_args=(--squash) fi -gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]}" "$@" +gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" ${merge_args[@]+"${merge_args[@]}"} "$@" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index f9d77114..4c50df11 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -679,7 +679,8 @@ if [ "$KIND" != secondmate ]; then # recorded shell-init noise (e.g. a transient ~/.oh-my-zsh cwd during zsh startup) # that appears before `treehouse get` lands. This needs no knowledge of the # treehouse pool root, so it works wherever treehouse.toml places it. - for _ in $(seq 1 60); do + wt_wait="${FM_SPAWN_WORKTREE_WAIT:-60}" + for _ in $(seq 1 "$wt_wait"); do p=$(spawn_current_path "$T" || true) if [ -n "$p" ] && is_project_worktree "$p" "$PROJ_ABS"; then WT="$p" @@ -688,7 +689,7 @@ if [ "$KIND" != secondmate ]; then sleep 1 done if [ -z "$WT" ]; then - echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 + echo "error: treehouse get did not enter a worktree within ${wt_wait}s; inspect window $T" >&2 exit 1 fi diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 92a697d8..001d3566 100755 --- a/tests/fm-tangle-guard.test.sh +++ b/tests/fm-tangle-guard.test.sh @@ -181,6 +181,7 @@ run_spawn() { 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_SPAWN_WORKTREE_WAIT=2 \ PATH="$fakebin:$PATH" \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex 2>&1 } @@ -196,9 +197,12 @@ test_spawn_isolation_abort() { mkdir -p "$TMP_ROOT/spawn-notgit" "$proj/sub" # Abort: the pane resolves to a plain non-git directory (not a worktree at all). + # The wait loop only captures a same-repo worktree, so a persistent non-git pane + # is treated as "treehouse never landed" and times out rather than reaching the + # isolation guard - still a clean abort (exit 1, no meta recorded). out=$(run_spawn "$home" abort-notgit-dd4 "$proj" "$TMP_ROOT/spawn-notgit" "$fakebin"); status=$? expect_code 1 "$status" "spawn into a non-worktree dir should abort" - assert_contains "$out" "did not yield an isolated worktree" "non-worktree spawn lacked the isolation error" + assert_contains "$out" "did not enter a worktree" "non-worktree spawn lacked the timeout abort" assert_absent "$home/state/abort-notgit-dd4.meta" "aborted spawn must not record meta" # Abort: the pane resolves INTO the primary checkout (a subdir of PROJ_ABS). From e1e1a77e5cb25de27d04bfcd1f6ec0db1dc4f971 Mon Sep 17 00:00:00 2001 From: Maxime Bourmaud Date: Fri, 3 Jul 2026 14:46:47 +0200 Subject: [PATCH 4/4] no-mistakes(document): document FM_SPAWN_WORKTREE_WAIT env var in configuration.md --- docs/configuration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.md b/docs/configuration.md index 5649c7a6..310c0068 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -213,6 +213,7 @@ FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' # busy-pane signatures, shared by watcher, fm-crew-state pane fallback, and tmux helper FM_COMPOSER_IDLE_RE= # optional empty-composer regex, applied after dim-ghost and border stripping GROK_HOME= # optional Grok config home for firstmate's global grok turn-end hook; defaults to ~/.grok +FM_SPAWN_WORKTREE_WAIT=60 # seconds fm-spawn waits for treehouse get to land the pane in a same-repo isolated worktree before aborting FM_SEND_RETRIES=3 # fm-send Enter-retry attempts after typing the line once FM_SEND_SLEEP=0.4 # seconds between fm-send submit checks FM_SEND_SETTLE=1 # seconds fm-send waits after a successful text submit; 0 disables