From 2a5fa5947b4fa2f30acdc859e5653cadb0fedb90 Mon Sep 17 00:00:00 2001 From: Paolo Sacconier Date: Sat, 11 Jul 2026 12:24:29 +0200 Subject: [PATCH 1/4] fix(spawn): skip treehouse get's transient cwd in worktree-discovery 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. --- bin/fm-spawn.sh | 75 +++++++++++++++++++++++++++-------- docs/herdr-backend.md | 8 ++++ tests/fm-backend.test.sh | 70 ++++++++++++++++++++++++++++++++ tests/fm-tangle-guard.test.sh | 1 + 4 files changed, 137 insertions(+), 17 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9a8a159a8..b8d8adc55 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -668,19 +668,25 @@ real_path_or_raw() { # # herdr-sm-spaces-k4). Both branches converge on the same $T ("target") string # that every downstream operation (send/capture/kill) already treats as opaque # per-backend routing (fm_backend_resolve_selector). +# is_isolated_worktree : non-fatal predicate, true (0) iff is the +# ROOT of a real git worktree physically distinct from the primary project +# checkout. Shared by the treehouse-get discovery poll (to skip transient +# intermediate cwds) and validate_spawn_worktree's fatal assertion, so the two +# use one definition of "a genuine isolated worktree". +is_isolated_worktree() { # + local wt=$1 wt_real wt_top wt_top_real + wt_real=$(cd "$wt" 2>/dev/null && pwd -P) || return 1 + [ -n "$wt_real" ] || return 1 + wt_top=$(git -C "$wt" rev-parse --show-toplevel 2>/dev/null) || return 1 + wt_top_real=$(cd "$wt_top" 2>/dev/null && pwd -P) || return 1 + [ "$wt_real" = "$wt_top_real" ] || return 1 + [ "$wt_real" != "$PROJ_ABS_REAL" ] || return 1 + return 0 +} validate_spawn_worktree() { # - local source=$1 inspect_target=$2 wt_real proj_real wt_top wt_top_real - wt_real= - if ! wt_real=$(cd "$WT" 2>/dev/null && pwd -P); then - wt_real= - fi - proj_real=$PROJ_ABS_REAL - wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true) - wt_top_real= - if ! wt_top_real=$(cd "$wt_top" 2>/dev/null && pwd -P); then - wt_top_real= - fi - if [ -z "$wt_real" ] || [ -z "$wt_top_real" ] || [ "$wt_real" != "$wt_top_real" ] || [ "$wt_real" = "$proj_real" ]; then + local source=$1 inspect_target=$2 wt_top + if ! is_isolated_worktree "$WT"; then + wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true) echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${wt_top:-none}'; primary '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2 exit 1 fi @@ -839,17 +845,52 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then # Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project # prefix would otherwise make the pane's OS-level cwd read differ from # PROJ_ABS on the very first poll, before the pane has actually moved. - for _ in $(seq 1 60); do + # + # Accept only a cwd that is a GENUINE isolated worktree, not merely the first + # non-project cwd: `treehouse get` transits intermediate cwds while it sets the + # worktree up (it is momentarily at `/` and at the treehouse root ~/.treehouse + # before it creates/enters the pooled worktree), and herdr's foreground_cwd + # exposes those transients (tmux's pane_current_path reports the settled + # interactive-shell cwd and never showed them). Grabbing a transient here made + # fm-spawn resolve WT to `/` or ~/.treehouse, fail the isolation gate below, + # and abort - orphaning the still-initializing treehouse get, which then held + # the pool slot forever with no crewmate (the herdr single-slot wedge). Gating + # on is_isolated_worktree keeps polling past the transients 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. + # + # moved_to remembers the last non-project cwd seen that was NOT (yet) a valid + # worktree: if the pane leaves the project but never reaches an isolated + # worktree within the budget (a genuine tangle, or treehouse get landing the + # pane somewhere invalid and staying), that path is handed to + # validate_spawn_worktree below so its precise "did not yield an isolated + # worktree (resolved '')" refusal still fires, rather than the vaguer + # never-moved timeout. A single point-in-time read cannot tell a settling + # transient apart from a permanent tangle, so we wait out the budget rather + # than risk a false refusal on a slow cold setup. + poll_tries=${FM_SPAWN_WORKTREE_POLL_TRIES:-60} + case "$poll_tries" in ''|*[!0-9]*|0) poll_tries=60 ;; esac + moved_to="" + for _ in $(seq 1 "$poll_tries"); do p=$(spawn_current_path "$WT_TARGET" || true) if [ -n "$p" ] && [ "$(real_path_or_raw "$p")" != "$PROJ_ABS_REAL" ]; then - WT="$p" - break + if is_isolated_worktree "$p"; then + WT="$p" + break + fi + moved_to="$p" fi sleep 1 done if [ -z "$WT" ]; then - echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 - exit 1 + if [ -n "$moved_to" ]; then + # Pane left the project but never reached an isolated worktree; let the + # gate emit its precise resolved-path refusal. + WT="$moved_to" + else + echo "error: treehouse get did not enter a worktree within ${poll_tries}s; inspect window $T" >&2 + exit 1 + fi fi validate_spawn_worktree "treehouse get" "$T" diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index edb795a85..141ce15f6 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -737,6 +737,14 @@ Covered by the unit cases in `tests/fm-afk-launch.test.sh` (clear-on-fresh-entry Verified with GNU bash 5.3.9(1)-release (aarch64-apple-darwin25.3.0) and git 2.53.0 on macOS (Darwin 25.5.0): added `tests/fm-backend.test.sh:test_spawn_symlinked_project_prefix_avoids_false_refusal`, which drives the real `bin/fm-spawn.sh` against fake-tmux panes whose first `pane_current_path` poll returns both the project's `pwd -P`-resolved physical path and its logical symlink-preserving path while `PROJ_ABS` is reached through a synthetic symlinked prefix (`ln -s `, project passed as `/proj`). Confirmed the test reproduces the original bug against the pre-fix script (`git stash` the `bin/fm-spawn.sh` change and rerun: `not ok - fm-spawn.sh should succeed for a project reached through a symlinked prefix` / `error: treehouse get did not yield an isolated worktree ...`), and passes against the fix (`bash tests/fm-backend.test.sh` reports `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`, with the rest of that suite's assertions unaffected). `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` passes clean on the changed scripts. +- **RESOLVED: the worktree-discovery poll grabbed `treehouse get`'s transient intermediate cwd (the herdr single-slot wedge).** Symptom (reproduced live 2026-07-11 against real herdr 0.7.3 + treehouse on a single-slot pool, backlog `herdr-fix-r5`): a herdr spawn aborted with `treehouse get did not yield an isolated worktree (resolved '/'; ...)` and left a live, stuck `treehouse get` holding the pool's only slot with no crewmate attached, so the next spawn found the pool exhausted. + Root cause is NOT process reaping: `treehouse get` transits intermediate cwds while it sets the worktree up - it is momentarily at `/` and at the treehouse root (`~/.treehouse`) before it creates and enters the pooled worktree (confirmed live: a cold `pane run 'treehouse get'` polled `foreground_cwd` = the project dir, then `/`, then the real `~/.treehouse//1/`; owner_pid is recorded only once it settles). + `fm-spawn.sh`'s poll accepted the FIRST cwd that merely differed from the project, so it captured `/` (or `~/.treehouse`), handed that transient to `validate_spawn_worktree`, and aborted - while the still-initializing `treehouse get` completed and held the slot forever. + herdr's `foreground_cwd` exposes those transients because it reads the actively-running foreground process's cwd; tmux's `pane_current_path` reports the settled interactive-shell cwd and never surfaced them, which is why only the herdr path wedged (verified reference backend stayed reliable). + Fix (`bin/fm-spawn.sh`): the worktree-discovery poll 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 it skips `/` and `~/.treehouse` and keeps polling until the pane settles in the actual worktree. + It is a no-op for tmux, whose first observed non-project cwd already is that worktree. + Verified: added `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-tmux pane whose polls return the project dir, then a transient `/`, then the real worktree; it reproduces the exact `resolved '/'` failure against the pre-fix poll (`not ok`) and passes against the fix, with the rest of that suite unaffected. + `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` passes clean. - **RESOLVED: a restart's restored-layout husk no longer needs a manual pane close before respawn.** See "Respawn idempotency: a restored task tab is a husk, not a duplicate" above for the fix (`fm_backend_herdr_pane_agent_state`, `fm_backend_herdr_create_task`'s close-and-replace). Left over from that fix: the `dead` (`pane_not_found`) husk classification is exercised only at the unit level, never against the real binary - killing a pane's process on a live server was observed to make herdr reap the whole tab immediately (never leaving a dead-but-still-listed pane for the duplicate check to find), and a real session restart was never observed to produce one either. It remains a conservative, defensively-coded path for a herdr failure mode (e.g. a restored process that fails to start) nobody has reproduced against the real binary yet. diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 5a9fafcde..7f88bf153 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -871,6 +871,75 @@ test_spawn_symlinked_project_prefix_avoids_false_refusal() { pass "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" } +# --- the worktree poll must skip treehouse-get's transient intermediate cwds -- +# +# Regression for the herdr single-slot wedge (task herdr-fix-r5): `treehouse get` +# transits intermediate cwds while it sets the worktree up - it is momentarily at +# `/` and at the treehouse root before it creates/enters the pooled worktree - and +# herdr's foreground_cwd exposes those transients (tmux's pane_current_path +# reported the settled cwd and never did). The old poll accepted the FIRST cwd +# that merely differed from the project, so it grabbed `/`, failed the isolation +# gate, and aborted - orphaning the still-initializing treehouse get, which then +# held the pool slot forever with no crewmate. The poll now gates on +# is_isolated_worktree, so it must skip a `/` transient and settle on the real +# worktree. make_spawn_transient_fakebin's stub returns the unmoved project path, +# then a transient `/`, then the real worktree, on successive polls. +make_spawn_transient_fakebin() { # -> echoes fakebin dir + local dir=$1 proj=$2 transient=$3 wt=$4 fb="$1/fakebin" counter="$1/poll-count" + mkdir -p "$fb" + : > "$counter" + cat > "$fb/tmux" <> "\${FM_TMUX_LOG:?}" +case "\${1:-}" in + display-message) + for a in "\$@"; do case "\$a" in *pane_current_path*) + printf x >> "$counter" + n=\$(wc -c < "$counter") + if [ "\$n" -le 1 ]; then + printf '%s\\n' "$proj" + elif [ "\$n" -le 2 ]; then + printf '%s\\n' "$transient" + else + printf '%s\\n' "$wt" + fi + exit 0 + ;; esac; done + printf 'firstmate\\n'; exit 0 ;; + list-windows) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + fm_fake_exit0 "$fb" treehouse + printf '%s\n' "$fb" +} + +test_spawn_worktree_poll_skips_treehouse_get_transient_cwd() { + local proj wt id fb data state config log out rc + proj="$TMP_ROOT/transient-proj" + wt="$TMP_ROOT/transient-wt" + id="spawntransient" + fm_git_worktree "$proj" "$wt" "fm/$id" + # First non-project poll returns `/` (the exact transient the real bug hit); + # the isolated-worktree gate must reject it and keep polling to the real wt. + fb=$(make_spawn_transient_fakebin "$TMP_ROOT/transient-fake" "$proj" "/" "$wt") + data="$TMP_ROOT/transient-data"; mkdir -p "$data/$id" + printf 'test brief content\n' > "$data/$id/brief.md" + state="$TMP_ROOT/transient-state"; config="$TMP_ROOT/transient-config" + mkdir -p "$state" "$config" + log="$TMP_ROOT/transient-spawn.log" + + out=$(run_spawn_case "$ROOT" "$fb" "$log" "$state" "$data" "$config" "$proj" -- "$id" "$proj" claude 2>&1) + rc=$? + expect_code 0 "$rc" "fm-spawn.sh should skip treehouse get's transient '/' cwd and settle on the real worktree"$'\n'"$out" + assert_contains "$out" "worktree=$wt" \ + "fm-spawn.sh resolved the worktree to a transient cwd instead of the real pooled worktree" + rm -rf "/tmp/fm-$id" + pass "fm-spawn.sh: the worktree-discovery poll skips treehouse get's transient intermediate cwds (/ , treehouse root) and resolves the real isolated worktree" +} + # --- old vs new: fm-teardown.sh ---------------------------------------------- make_teardown_fakebin() { # -> echoes fakebin dir; logs tmux+treehouse calls @@ -1082,6 +1151,7 @@ test_backend_of_selector_matches_explicit_target_meta test_send_conformance_old_vs_new test_peek_conformance_old_vs_new test_spawn_symlinked_project_prefix_avoids_false_refusal +test_spawn_worktree_poll_skips_treehouse_get_transient_cwd test_teardown_conformance_old_vs_new test_spawn_refuses_unknown_backend_flag test_spawn_refuses_codex_app_backend_flag diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index a6f24eaa5..4c5397d8f 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_POLL_TRIES=2 \ PATH="$fakebin:$PATH" \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex 2>&1 } From fdf32d7902dafc99fe2485886199cf25810c513d Mon Sep 17 00:00:00 2001 From: Paolo Sacconier Date: Fri, 17 Jul 2026 09:40:18 +0200 Subject: [PATCH 2/4] fix(watcher): stop per-poll stale runaway on a finished-and-parked crew A crew that finished its pipeline (authoritative state done, e.g. a PR raised and monitoring for merge/close) and then appended a `paused: awaiting-decision` last line re-surfaced a stale wake on every poll. The suppressor-equal paused-last-line branch re-classified via pause_state_class, whose `none` verdict (crew_absorb_class maps done -> none) fell through to surface_nonterminal_stale with no cadence gate; surface then deleted the pause/wedge markers, so nothing could ever throttle it. Each wake exits the watcher and it re-arms, so a done and parked pane burned a supervisor turn every 20-30s. Add crew_reached_terminal (done or parked) and, on the already-surfaced (suppressor-equal) hash, route a terminal crew to handle_paused_stale for a long pause-cadence recheck while a genuinely stopped or unknown crew keeps surfacing. The first-sight arm is untouched, so the finish is still surfaced once; provably-working (#233) and declared-pause (#421) crews are unaffected. Regression tests cover the five cases in tests/fm-watch-triage.test.sh. --- bin/fm-classify-lib.sh | 19 ++++ bin/fm-watch.sh | 13 ++- tests/fm-watch-triage.test.sh | 195 ++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index db39f4c8a..67cfb160f 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -321,6 +321,25 @@ crew_is_paused() { # [ "$(crew_absorb_class "$1")" = paused ] } +# 0 if crew 's authoritative current state is a TERMINAL, already-surfaced-once +# run outcome: done (finished, e.g. a PR raised and monitoring for merge/close) or +# parked (stopped at a gate awaiting a decision). This is deliberately NARROWER than +# crew_absorb_class's `none`, which also covers failed/unknown/torn-down crews that +# carry no positive terminal confirmation. The stale path uses it to tell a +# finished-and-parked crew (already noticed, so re-check on the long pause cadence) +# apart from a genuinely stopped one (keep surfacing). Reads the same one +# authoritative fm-crew-state.sh line as crew_absorb_class; NOT a pure read (that +# reader may make a bounded no-mistakes call), so callers run it only on the stale +# paths, never on every wake. FM_CREW_STATE_BIN lets tests stub the verdict. +crew_reached_terminal() { # + local id=$1 line state + [ -n "$id" ] || return 1 + line=$("$FM_CREW_STATE_BIN" "$id" 2>/dev/null) || true + case "$line" in state:*) ;; *) return 1 ;; esac + state=${line#state: }; state=${state%% *} + case "$state" in done|parked) return 0 ;; *) return 1 ;; esac +} + # 0 (benign/absorb) if EVERY task referenced by a no-verb "signal:" wake is provably # working; 1 (actionable/surface) if any is not, or no task can be resolved. Pass the # same space-separated file list as signal_reason_is_actionable. Files are mapped to diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 4ea6da70a..abe3ba9b2 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -957,7 +957,18 @@ EOF printf '%s' "$h" > "$sf" wedge_timer_check "$w" "$ssf" "non-terminal stale (provably working after a declared pause)" "$ewf" triage_log "absorbed non-terminal stale (provably working): $w" ;; - *) surface_nonterminal_stale "$w" "$h" ;; + *) # No working/paused evidence, and this hash was already + # surfaced once (first-sight arm above). A crew whose + # authoritative state is terminal (done - e.g. a PR raised and + # monitoring for merge/close - or parked at a gate) is awaiting + # the captain, not wedged: re-check it on the long pause cadence + # instead of re-surfacing every poll. A genuinely stopped or + # unknown crew (no terminal confirmation) keeps surfacing. + if crew_reached_terminal "$task"; then + handle_paused_stale "$w" "$task" "$h" + else + surface_nonterminal_stale "$w" "$h" + fi ;; esac else wedge_timer_check "$w" "$ssf" "non-terminal stale" "$ewf" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 4cbc4de4f..8f57f3966 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1118,6 +1118,196 @@ test_afk_paused_changed_pane_hands_off_plain_stale() { pass "AFK changed paused panes hand off plain stale identities for daemon-owned pause triage" } +# --- finished-and-parked crew with a paused: LAST status line ----------------- +# Regression for the watcher runaway (RCA watcher-stale-rca-k2): a crew that +# FINISHED its pipeline (authoritative state `done`, e.g. a PR raised and monitoring +# for merge/close) and then appended a `paused: awaiting-captain-decision` line used +# to re-surface a `stale:` wake on EVERY poll. The suppressor-equal paused-last-line +# branch re-classified via pause_state_class, whose `none` verdict (crew_absorb_class +# maps `done` -> none) fell through to surface_nonterminal_stale with no cadence gate; +# surface then deleted the pause/wedge markers, so nothing could ever throttle it. +# The fix: on the ALREADY-surfaced (suppressor-equal) hash, a terminal crew +# (crew_reached_terminal: done/parked) is re-checked on the long pause cadence via +# handle_paused_stale, while a genuinely stopped/unknown crew keeps surfacing. + +# Case 2: the finish is still SURFACED ONCE on the first sight of the hash (the +# first-sight arm is untouched by the fix). +test_doneparked_paused_finish_surfaces_once() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case doneparked-first-sight); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt" + window="test:fm-doneparked-first" + printf 'idle pane, finished, awaiting decision' > "$capture_file" + printf 'window=%s\nkind=ship\nmode=no-mistakes\n' "$window" > "$state/doneparked-first.meta" + printf 'paused: awaiting-captain-decision on [key=nm-pr-scope] recheck on a long cadence\n' > "$state/doneparked-first.status" + sig=$(seen_sig "$state/doneparked-first.status"); printf '%s' "$sig" > "$state/.seen-doneparked-first_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "idle pane, finished, awaiting decision") + printf '%s' "$pane_hash" > "$state/.hash-$key" # hash known, but NOT yet surfaced (.stale absent) + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: done · source: run-step · checks green: PR ready for review (still monitoring for merge/close)' + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "the finish of a done-and-parked crew was not surfaced on the first sight of its hash" + grep -Fx "stale: $window" "$out" >/dev/null || fail "first sight did not surface the plain stale wake: $(cat "$out")" + [ "$(cat "$state/.stale-$key" 2>/dev/null || true)" = "$pane_hash" ] || fail "first-sight surface did not advance the stale suppressor" + unset FM_FAKE_CREW_STATE + pass "a done-and-parked crew's finish is surfaced once on first sight (finish still noticed)" +} + +# Case 1 + long cadence: on the ALREADY-surfaced hash, the same crew is ABSORBED +# (no more per-poll runaway), then re-surfaced ONCE past PAUSE_RESURFACE_SECS as a +# paused recheck, never a wedge. +test_doneparked_paused_repeat_rechecked_not_resurfaced() { + local dir state fakebin out drain_out capture_file window key pane_hash sig pid back statusf + dir=$(make_case doneparked-repeat); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; drain_out="$dir/drain.out"; capture_file="$dir/pane.txt" + window="test:fm-doneparked-repeat" + printf 'idle pane, finished, awaiting decision' > "$capture_file" + printf 'window=%s\nkind=ship\nmode=no-mistakes\n' "$window" > "$state/doneparked-repeat.meta" + statusf="$state/doneparked-repeat.status" + printf 'paused: awaiting-captain-decision on [key=nm-pr-scope] recheck on a long cadence\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-doneparked-repeat_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "idle pane, finished, awaiting decision") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '%s' "$pane_hash" > "$state/.stale-$key" # already surfaced once on this hash + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: done · source: run-step · checks green: PR ready for review (still monitoring for merge/close)' + + # Phase A: fresh pause under a high re-surface threshold -> absorbed. Before the + # fix this poll re-surfaced a stale wake and exited. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "watcher re-surfaced an already-noticed done-and-parked crew instead of absorbing: $(cat "$out")" + fi + [ ! -s "$out" ] || { reap "$pid"; fail "the absorbed done-and-parked recheck printed a wake reason: $(cat "$out")"; } + [ ! -s "$state/.wake-queue" ] || { reap "$pid"; fail "the absorbed done-and-parked recheck enqueued a wake"; } + [ -e "$state/.paused-$key" ] || { reap "$pid"; fail "the terminal recheck did not record the long-cadence pause flag"; } + [ ! -e "$state/.stale-since-$key" ] || { reap "$pid"; fail "the terminal recheck armed a wedge timer (should use the long pause cadence)"; } + reap "$pid" + + # Phase B: age the pause past the (now normal) threshold; re-surfaces once as a + # paused recheck, never a wedge. + back=$(( $(date +%s) - 500 )) + if [ "$(uname)" = Darwin ]; then touch -mt "$(date -r "$back" '+%Y%m%d%H%M.%S')" "$statusf" + else touch -m -d "@$back" "$statusf"; fi + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-doneparked-repeat_status" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "the aged done-and-parked recheck did not re-surface past the threshold" + grep -F "stale: $window" "$out" >/dev/null || fail "the aged recheck did not print a stale wake" + grep -F "awaiting external" "$out" >/dev/null || fail "the aged recheck was not labeled a paused/awaiting-external recheck" + grep -F "possible wedge" "$out" >/dev/null && fail "the aged done-and-parked recheck was mislabeled a possible wedge" + [ -e "$state/.paused-resurfaced-$key" ] || fail "the paused re-surface throttle marker was not recorded" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after the aged recheck failed" + grep "$(printf '\tstale\t')" "$drain_out" | grep -F "$window" >/dev/null || fail "the aged recheck was not queued" + unset FM_FAKE_CREW_STATE + pass "an already-noticed done-and-parked crew is absorbed, then re-surfaced once past the long pause cadence (no per-poll runaway)" +} + +# Case 3 (#233 guard): the same suppressor-equal paused-last-line pane whose +# authoritative state is WORKING (active validation) stays absorbed - the fix must +# not move a provably-working crew off the working arm. +test_doneparked_paused_working_crew_absorbs() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case doneparked-working); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt" + window="test:fm-doneparked-working" + printf 'idle validating pane' > "$capture_file" + printf 'window=%s\nkind=ship\nmode=no-mistakes\n' "$window" > "$state/doneparked-working.meta" + printf 'paused: awaiting-captain-decision on [key=nm-pr-scope] recheck on a long cadence\n' > "$state/doneparked-working.status" + sig=$(seen_sig "$state/doneparked-working.status"); printf '%s' "$sig" > "$state/.seen-doneparked-working_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "idle validating pane") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '%s' "$pane_hash" > "$state/.stale-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: working · source: run-step · validating (running)' + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a provably-working crew with a paused last line was surfaced (must stay absorbed): $(cat "$out")" + fi + [ ! -s "$out" ] || { reap "$pid"; fail "the provably-working paused-last-line pane printed a wake reason: $(cat "$out")"; } + [ ! -e "$state/.paused-$key" ] || { reap "$pid"; fail "a provably-working crew was tracked as a declared pause"; } + reap "$pid" + unset FM_FAKE_CREW_STATE + pass "a provably-working crew with a paused last line stays absorbed on the wedge timer (#233 protection intact)" +} + +# Case 4 (#421 guard): the same pane whose authoritative state is a genuine PAUSE +# is absorbed on the pause cadence, as before the fix. +test_doneparked_paused_paused_crew_absorbs() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case doneparked-paused); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt" + window="test:fm-doneparked-paused" + printf 'idle holding for upstream' > "$capture_file" + printf 'window=%s\nkind=ship\nmode=no-mistakes\n' "$window" > "$state/doneparked-paused.meta" + printf 'paused: holding for the upstream release\n' > "$state/doneparked-paused.status" + sig=$(seen_sig "$state/doneparked-paused.status"); printf '%s' "$sig" > "$state/.seen-doneparked-paused_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "idle holding for upstream") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '%s' "$pane_hash" > "$state/.stale-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: paused · source: status-log · holding for the upstream release' + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a genuinely paused crew was surfaced (must stay absorbed on the pause cadence): $(cat "$out")" + fi + [ ! -s "$out" ] || { reap "$pid"; fail "the genuinely paused pane printed a wake reason: $(cat "$out")"; } + [ -e "$state/.paused-$key" ] || { reap "$pid"; fail "a genuinely paused crew did not record the pause flag"; } + reap "$pid" + unset FM_FAKE_CREW_STATE + pass "a genuinely paused crew with a paused last line is absorbed on the pause cadence (#421 behavior intact)" +} + +# Case 5: a genuinely STOPPED/unknown crew with a paused last line keeps surfacing +# on the suppressor-equal hash - the terminal carve-out must not over-absorb a crew +# with no positive terminal confirmation. +test_doneparked_paused_stopped_crew_surfaces() { + local dir state fakebin out drain_out capture_file window key pane_hash sig pid + dir=$(make_case doneparked-stopped); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; drain_out="$dir/drain.out"; capture_file="$dir/pane.txt" + window="test:fm-doneparked-stopped" + printf 'idle dead pane' > "$capture_file" + printf 'window=%s\nkind=ship\nmode=no-mistakes\n' "$window" > "$state/doneparked-stopped.meta" + printf 'paused: awaiting-captain-decision on [key=nm-pr-scope] recheck on a long cadence\n' > "$state/doneparked-stopped.status" + sig=$(seen_sig "$state/doneparked-stopped.status"); printf '%s' "$sig" > "$state/.seen-doneparked-stopped_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "idle dead pane") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '%s' "$pane_hash" > "$state/.stale-$key" + printf '1\n' > "$state/.count-$key" + export FM_FAKE_CREW_STATE='state: unknown · source: none · worktree gone' + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "a genuinely stopped/unknown crew with a paused last line was over-absorbed (must surface)" + grep -Fx "stale: $window" "$out" >/dev/null || fail "the stopped crew did not surface a plain stale wake: $(cat "$out")" + [ ! -e "$state/.paused-$key" ] || fail "a stopped/unknown crew was tracked as a declared pause" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || fail "drain after the stopped-crew surface failed" + grep "$(printf '\tstale\t')" "$drain_out" | grep -F "$window" >/dev/null || fail "the stopped-crew stale was not queued" + unset FM_FAKE_CREW_STATE + pass "a genuinely stopped/unknown crew with a paused last line keeps surfacing (terminal carve-out does not over-absorb)" +} + test_signal_reason_is_actionable_classifier test_stale_is_terminal_classifier test_scan_captain_relevant_statuses_classifier @@ -1151,3 +1341,8 @@ test_heartbeat_backstop_surfaces_unsurfaced_status test_beacon_stays_fresh_while_absorbing test_afk_present_reverts_watcher_to_one_shot test_afk_paused_changed_pane_hands_off_plain_stale +test_doneparked_paused_finish_surfaces_once +test_doneparked_paused_repeat_rechecked_not_resurfaced +test_doneparked_paused_working_crew_absorbs +test_doneparked_paused_paused_crew_absorbs +test_doneparked_paused_stopped_crew_surfaces From 23456992ccf9f05b3be7e416a9d2aafcb8716f77 Mon Sep 17 00:00:00 2001 From: Paolo Sacconier Date: Fri, 17 Jul 2026 14:34:20 +0200 Subject: [PATCH 3/4] fix(watcher): throttle crew-state reads on a terminal-parked stale crew The terminal -> long-cadence recheck route never wrote the .paused-rechecked- marker, so pause_state_class's zero-read fast path never engaged and every poll re-ran two authoritative fm-crew-state.sh reads (crew_absorb_class + crew_reached_terminal) for the crew's entire parked lifetime. Record the marker when the terminal route absorbs, restoring the first-sight-only read invariant; the read re-verifies on the existing STALE_ESCALATE_SECS cadence. Adds a call-counting regression test proving the read does not repeat per poll. --- bin/fm-watch.sh | 1 + tests/fm-watch-triage.test.sh | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index abe3ba9b2..d37ddd1c8 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -965,6 +965,7 @@ EOF # instead of re-surfacing every poll. A genuinely stopped or # unknown crew (no terminal confirmation) keeps surfacing. if crew_reached_terminal "$task"; then + date +%s > "$STATE/.paused-rechecked-$key" handle_paused_stale "$w" "$task" "$h" else surface_nonterminal_stale "$w" "$h" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 8f57f3966..3fd480700 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1214,6 +1214,47 @@ test_doneparked_paused_repeat_rechecked_not_resurfaced() { pass "an already-noticed done-and-parked crew is absorbed, then re-surfaced once past the long pause cadence (no per-poll runaway)" } +# The terminal recheck must also be READ-throttled: the authoritative +# fm-crew-state.sh read (crew_absorb_class + crew_reached_terminal) runs only on +# the first sight of a terminal-parked stale hash, then the .paused-rechecked-* +# marker keeps pause_state_class on its zero-read fast path until +# STALE_ESCALATE_SECS elapses. Counts real invocations of a counting fake across +# several polls to prove the read is not repeated per poll. +test_doneparked_paused_repeat_reads_throttled() { + local dir state fakebin out capture_file window key pane_hash sig pid calls reads + dir=$(make_case doneparked-read-throttle); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; calls="$dir/crew-state.calls" + window="test:fm-doneparked-reads" + printf 'idle pane, finished, awaiting decision' > "$capture_file" + printf 'window=%s\nkind=ship\nmode=no-mistakes\n' "$window" > "$state/doneparked-reads.meta" + printf 'paused: awaiting-captain-decision on [key=nm-pr-scope] recheck on a long cadence\n' > "$state/doneparked-reads.status" + sig=$(seen_sig "$state/doneparked-reads.status"); printf '%s' "$sig" > "$state/.seen-doneparked-reads_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "idle pane, finished, awaiting decision") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '%s' "$pane_hash" > "$state/.stale-$key" # already surfaced once on this hash + printf '1\n' > "$state/.count-$key" + cat > "$fakebin/fm-crew-state.sh" <> "$calls" +printf '%s\n' "state: done · source: run-step · checks green: PR ready for review (still monitoring for merge/close)" +SH + chmod +x "$fakebin/fm-crew-state.sh" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_PAUSE_RESURFACE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 60; then + reap "$pid"; fail "watcher exited while absorbing a read-throttled done-and-parked crew: $(cat "$out")" + fi + reap "$pid" + [ -e "$state/.paused-rechecked-$key" ] || fail "the terminal recheck did not record the read-throttle marker" + reads=$(wc -l < "$calls" 2>/dev/null | tr -d ' ') + [ -n "$reads" ] && [ "$reads" -ge 1 ] || fail "the first sight never read the authoritative crew state" + [ "$reads" -le 2 ] || fail "the crew-state read repeated across polls ($reads reads) instead of throttling to first sight" + pass "a done-and-parked crew's authoritative state is read on first sight only, then throttled ($reads reads over multiple polls)" +} + # Case 3 (#233 guard): the same suppressor-equal paused-last-line pane whose # authoritative state is WORKING (active validation) stays absorbed - the fix must # not move a provably-working crew off the working arm. @@ -1343,6 +1384,7 @@ test_afk_present_reverts_watcher_to_one_shot test_afk_paused_changed_pane_hands_off_plain_stale test_doneparked_paused_finish_surfaces_once test_doneparked_paused_repeat_rechecked_not_resurfaced +test_doneparked_paused_repeat_reads_throttled test_doneparked_paused_working_crew_absorbs test_doneparked_paused_paused_crew_absorbs test_doneparked_paused_stopped_crew_surfaces From 4eefcbf904e1b4cc0710c00d9266c52ce7cb3171 Mon Sep 17 00:00:00 2001 From: Paolo Sacconier Date: Fri, 17 Jul 2026 15:29:29 +0200 Subject: [PATCH 4/4] no-mistakes(document): docs: sync watcher terminal-stale throttle and spawn poll knob --- bin/fm-watch.sh | 5 ++++- docs/architecture.md | 1 + docs/configuration.md | 3 ++- docs/herdr-backend.md | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index d37ddd1c8..a5b3fddcd 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -20,7 +20,10 @@ # line, since the crew's own log gets no new entry once # firstmate hands it to a no-mistakes validation. A declared # external-wait pause is absorbed instead with its own long -# re-surface cadence, never as a wedge. Only when neither +# re-surface cadence, never as a wedge; a paused crew whose +# authoritative state has since reached done/parked surfaces +# once, then re-checks on that same long cadence instead of +# re-surfacing every poll. Only when neither # absorb class applies does the log's last line decide: # terminal (captain-relevant) or non-terminal (no verb), # both surfaced at once. A provably-working stale past the diff --git a/docs/architecture.md b/docs/architecture.md index 6e8ad3f0f..adc6d2340 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,6 +14,7 @@ Repeated provably-working stale escalations on the same unchanged pane add an es Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue. No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step for that crew's branch or a backend busy signature. A crew that declares `paused:` for a known external wait is separately absorbed while idle and re-surfaced only on the longer pause cadence, rather than being treated as a possible wedge. +A paused-declared crew whose run then finishes surfaces its stale pane once; when the authoritative current state confirms a terminal outcome - done (e.g. a PR raised, monitoring for merge/close) or parked at a gate - later polls re-check it on that same long pause cadence instead of re-surfacing every poll, with the current-state read itself throttled to first sight per `FM_STALE_ESCALATE_SECS` window. Its initial normal-mode status signal still surfaces through the no-verb path, while away mode self-handles that routine signal and owns the later recheck. Fresh stale panes use the same current-state read before trusting the status log, so an active run or busy pane outranks an old captain-relevant status-log line left behind before validation. No-change heartbeats are also benign. diff --git a/docs/configuration.md b/docs/configuration.md index 6920d919c..738fcd82c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -345,6 +345,7 @@ FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted +FM_SPAWN_WORKTREE_POLL_TRIES=60 # 1s attempts fm-spawn.sh's worktree-discovery poll waits for treehouse get's pane to settle in an isolated worktree (non-orca ship/scout spawns); blank, zero, or non-numeric falls back to 60 HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping @@ -370,7 +371,7 @@ FM_CHECK_TIMEOUT=30 # seconds allowed per slow check script FM_CODEX_WATCH_CHECKPOINT=180 # seconds per foreground watcher checkpoint in Codex primary supervision FM_CREW_STATE_NM_TIMEOUT=10 # seconds allowed per no-mistakes query inside fm-crew-state.sh FM_CREW_STATE_RUNS_LIMIT=200 # recent no-mistakes runs rows scanned when cross-branch attribution falls back from axi status -FM_CREW_STATE_BIN=bin/fm-crew-state.sh # test override for the current-state reader used by working/paused watcher triage +FM_CREW_STATE_BIN=bin/fm-crew-state.sh # test override for the current-state reader used by working/paused/terminal-parked watcher triage FMX_PAIRING_TOKEN= # X mode pairing token; .env opt-in authorizes replies and eligible lifecycle actions FMX_RELAY_URL=https://myfirstmate.io # optional X relay override, mainly for local relay development FMX_ENV_FILE= # optional alternate .env file for direct X client invocations; bootstrap still checks $FM_HOME/.env diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index e65a7069b..7c95ec338 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -882,7 +882,7 @@ Covered by the unit cases in `tests/fm-afk-launch.test.sh` (clear-on-fresh-entry Root cause is NOT process reaping: `treehouse get` transits intermediate cwds while it sets the worktree up - it is momentarily at `/` and at the treehouse root (`~/.treehouse`) before it creates and enters the pooled worktree (confirmed live: a cold `pane run 'treehouse get'` polled `foreground_cwd` = the project dir, then `/`, then the real `~/.treehouse//1/`; owner_pid is recorded only once it settles). `fm-spawn.sh`'s poll accepted the FIRST cwd that merely differed from the project, so it captured `/` (or `~/.treehouse`), handed that transient to `validate_spawn_worktree`, and aborted - while the still-initializing `treehouse get` completed and held the slot forever. herdr's `foreground_cwd` exposes those transients because it reads the actively-running foreground process's cwd; tmux's `pane_current_path` reports the settled interactive-shell cwd and never surfaced them, which is why only the herdr path wedged (verified reference backend stayed reliable). - Fix (`bin/fm-spawn.sh`): the worktree-discovery poll 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 it skips `/` and `~/.treehouse` and keeps polling until the pane settles in the actual worktree. + Fix (`bin/fm-spawn.sh`): the worktree-discovery poll 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 it skips `/` and `~/.treehouse` and keeps polling until the pane settles in the actual worktree (poll budget tunable via `FM_SPAWN_WORKTREE_POLL_TRIES`, default 60 one-second attempts; [configuration.md](configuration.md)). It is a no-op for tmux, whose first observed non-project cwd already is that worktree. Verified: added `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-tmux pane whose polls return the project dir, then a transient `/`, then the real worktree; it reproduces the exact `resolved '/'` failure against the pre-fix poll (`not ok`) and passes against the fix, with the rest of that suite unaffected. `shellcheck bin/*.sh bin/backends/*.sh tests/*.sh` passes clean.