Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ projects/
state/
data/
.no-mistakes/
.omc/
.lavish/
.DS_Store
.env
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ tests/fm-tangle-guard.test.sh # primary-checkout tangle detection, r
tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166) and clean no-mistakes/direct-PR/local-only brief generation tests
tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests
tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop, harness/model/effort meta, launch templates, batch forwarding, and secondmate exemption
tests/fm-spawn-symlink-wait.test.sh # treehouse wait loop keeps polling through a symlinked project's physical-vs-logical pane-path mismatch instead of capturing the primary checkout as the worktree
tests/fm-gotmp.test.sh # per-task GOTMPDIR temp root: fm-spawn's /tmp/fm-<id>/gotmp export and tasktmp= meta, and fm-teardown's tasktmp= cleanup
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
tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resolution, optional secondmate model/effort pins, primary-to-secondmate config inheritance, and config-push tests
Expand Down
10 changes: 9 additions & 1 deletion bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -789,9 +789,17 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
spawn_send_text_line "$T" 'treehouse get'

# Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree.
# Backends report the pane's PHYSICAL cwd, while PROJ_ABS is logical; through a
# symlinked projects/<name> the two differ from the very first poll, which would
# break the loop before treehouse moves the pane and capture the primary checkout
# as WT. Compare against both forms so the loop waits until the pane really moves.
if ! PROJ_PHYS=$(cd "$PROJ_ABS" 2>/dev/null && pwd -P); then
echo "error: cannot resolve physical path of project '$PROJ_ABS'" >&2
exit 1
fi
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" ] && [ "$p" != "$PROJ_PHYS" ]; then
WT="$p"
break
fi
Expand Down
6 changes: 3 additions & 3 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ You do not need to attach for routine supervision: `bin/fm-peek.sh fm-<id>` read

Verify it works by spawning a trivial task with `--backend herdr` and confirming the task's meta records `backend=herdr` plus `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`; the workspace for your home should show the new `fm-<id>` tab.

Limitations: herdr is experimental, not yet used for `bin/fm-bootstrap.sh`'s required-tools list (the version/tool gate happens at spawn time instead), and carries the known gaps documented below (a small-`--lines` capture bug with a built-in workaround, and a `pane_cwd`-adjacent worktree-discovery symlink fragility) - see "Known gaps and follow-up notes" at the end of this document.
Limitations: herdr is experimental, not yet used for `bin/fm-bootstrap.sh`'s required-tools list (the version/tool gate happens at spawn time instead), and carries the known gaps documented below (a small-`--lines` capture bug with a built-in workaround) - see "Known gaps and follow-up notes" at the end of this document.

## Status: experimental

Expand Down Expand Up @@ -377,8 +377,8 @@ This is a test-harness-only concern - `fm_backend_herdr_composer_state` and `fm_
A genuine `events.subscribe`-driven push is a reasonable follow-up, not implemented here.
- **`bin/fm-bootstrap.sh`'s required-tools list is unchanged.** It still unconditionally requires `tmux`, and does not yet conditionally add `herdr` and `jq` when a backend selection resolves to herdr.
The version/tool gate happens at spawn time instead and refuses loudly, so this is bootstrap-detection polish, not a functional gap.
- **Worktree-discovery isolation guard is symlink-fragile for a project path under a symlinked prefix (e.g. macOS's `/tmp` -> `/private/tmp`).** Discovered while building the runtime-backend-auto-detection real smoke test (`tests/fm-backend-autodetect-smoke.test.sh`), which needed a scratch project. `fm-spawn.sh`'s `PROJ_ABS` is a LOGICAL `cd && pwd` (symlink components kept), while herdr's `foreground_cwd` (and real tmux's `pane_current_path`, on the same OS-level cwd primitive) report the PHYSICALLY resolved path.
When the project itself lives under a symlinked directory, the very first worktree-discovery poll sees two different strings for the identical starting directory and the isolation guard false-refuses the spawn as "not isolated" before `treehouse get` ever moves the pane - backend-agnostic, not specific to herdr. Worked around in the test by resolving its scratch `TMP_ROOT` through `pwd -P` before use; the underlying `fm-spawn.sh` path-comparison gap (worth resolving `PROJ_ABS` physically, or comparing physically-resolved forms in the isolation guard) is unfixed and worth a dedicated follow-up.
- **RESOLVED: worktree discovery is no longer symlink-fragile for a project path under a symlinked prefix (e.g. macOS's `/tmp` -> `/private/tmp`).** Discovered while building the runtime-backend-auto-detection real smoke test (`tests/fm-backend-autodetect-smoke.test.sh`), which needed a scratch project. `fm-spawn.sh`'s `PROJ_ABS` is a LOGICAL `cd && pwd` (symlink components kept), while herdr's `foreground_cwd` (and real tmux's `pane_current_path`, on the same OS-level cwd primitive) report the PHYSICALLY resolved path.
When the project itself lived under a symlinked directory, the very first worktree-discovery poll saw two different strings for the identical starting directory, broke the wait loop before `treehouse get` ever moved the pane, captured the project checkout as the worktree, and the isolation guard false-refused the spawn as "not isolated" - backend-agnostic, not specific to herdr. Fixed in `fm-spawn.sh`'s wait loop, which now also resolves the project path physically (`pwd -P`) and keeps polling until the pane's path differs from both the logical and physical forms; regression-covered by `tests/fm-spawn-symlink-wait.test.sh`. (`tests/fm-backend-autodetect-smoke.test.sh` still resolves its scratch `TMP_ROOT` through `pwd -P` before use, which is now belt-and-suspenders rather than load-bearing.)
- **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.
9 changes: 6 additions & 3 deletions tests/fm-backend-autodetect-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (requi
# ${TMPDIR:-/tmp} path: on macOS /tmp is a symlink to /private/tmp, and
# fm-spawn.sh's PROJ_ABS uses a logical `cd && pwd` while herdr's own
# foreground_cwd reports the OS-resolved physical path. A project rooted on
# the logical side of that symlink makes the very first worktree-discovery
# poll see two different STRINGS for the same directory and trip the
# isolation guard's false-refusal before treehouse ever moves the pane.
# the logical side of that symlink used to make the very first
# worktree-discovery poll see two different STRINGS for the same directory
# and trip the isolation guard's false-refusal before treehouse ever moved
# the pane. fm-spawn.sh's wait loop now compares both the logical and
# physical forms (regression-covered by tests/fm-spawn-symlink-wait.test.sh),
# so this pre-resolution is belt-and-suspenders rather than load-bearing.
TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-backend-autodetect-smoke.XXXXXX")
SESSION="fm-autodetect-smoke-$$"
export HERDR_SESSION="$SESSION"
Expand Down
94 changes: 94 additions & 0 deletions tests/fm-spawn-symlink-wait.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Behavior test for fm-spawn.sh's treehouse wait loop through a SYMLINKED
# project dir.
#
# Backends report the pane's PHYSICAL cwd, but PROJ_ABS is resolved with a
# logical pwd. Through a symlinked projects/<name>, physical != logical from
# the very first poll, so the old break condition (`p != PROJ_ABS`) fired
# before treehouse moved the pane, captured the project checkout as WT, and
# the isolation guard refused the launch. The fake tmux here returns the
# project's physical path for the first polls and the worktree afterwards,
# so the loop must keep waiting through the symlink mismatch to succeed.
set -u

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

SPAWN="$ROOT/bin/fm-spawn.sh"
TMP_ROOT=$(fm_test_tmproot fm-spawn-symlink-wait)

# Fake tmux whose pane-path query is stateful: calls 1..FM_FAKE_PANE_MOVE_AFTER
# return FM_FAKE_PANE_PATH_BEFORE (the pane still sitting in the project),
# later calls return FM_FAKE_PANE_PATH (treehouse moved it to the worktree).
make_symlink_spawn_fakebin() {
local dir=$1 fakebin
fakebin=$(fm_fakebin "$dir")
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
set -u
case "$*" in
*"#{pane_current_path}"*)
n=0
if [ -n "${FM_FAKE_PANE_COUNT:-}" ] && [ -f "$FM_FAKE_PANE_COUNT" ]; then
n=$(cat "$FM_FAKE_PANE_COUNT")
fi
n=$((n + 1))
[ -z "${FM_FAKE_PANE_COUNT:-}" ] || printf '%s\n' "$n" > "$FM_FAKE_PANE_COUNT"
if [ "$n" -le "${FM_FAKE_PANE_MOVE_AFTER:-0}" ]; then
printf '%s\n' "${FM_FAKE_PANE_PATH_BEFORE:-}"
else
printf '%s\n' "${FM_FAKE_PANE_PATH:-}"
fi
exit 0
;;
esac
case "${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
list-windows) exit 0 ;;
has-session|new-session|new-window|kill-window) exit 0 ;;
send-keys) exit 0 ;;
esac
exit 0
SH
chmod +x "$fakebin/tmux"
fm_fake_exit0 "$fakebin" treehouse
printf '%s\n' "$fakebin"
}

test_symlinked_project_waits_for_worktree_move() {
local id case_dir home proj proj_link proj_phys wt fakebin count out status
id=symlink-wait-z1
case_dir="$TMP_ROOT/symlink-wait"
home="$case_dir/home"
proj="$case_dir/project"
proj_link="$case_dir/project-link"
wt="$case_dir/wt"
count="$case_dir/pane-count"
fakebin=$(make_symlink_spawn_fakebin "$case_dir/fake")
mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config"
printf 'claude\n' > "$home/config/crew-harness"
printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md"
touch "$home/state/.last-watcher-beat"
fm_git_worktree "$proj" "$wt" "wt-$id"
ln -s "$proj" "$proj_link"
proj_phys=$(cd "$proj" && pwd -P)

out=$(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 TMUX="fake,1,0" PATH="$fakebin:$PATH" \
FM_FAKE_PANE_COUNT="$count" FM_FAKE_PANE_MOVE_AFTER=2 \
FM_FAKE_PANE_PATH_BEFORE="$proj_phys" FM_FAKE_PANE_PATH="$wt" \
"$SPAWN" "$id" "$proj_link" 2>&1)
status=$?
expect_code 0 "$status" "spawn through a symlinked project dir should wait out the pane's physical-cwd polls and succeed"
assert_not_contains "$out" "did not yield an isolated worktree" \
"spawn treated the pane's physical project cwd as the worktree move"
assert_contains "$out" "worktree=$wt" "spawn did not capture the real worktree path"
assert_grep "worktree=$wt" "$home/state/$id.meta" "meta did not record the real worktree"
pass "wait loop keeps polling while a symlinked project's pane reports its physical cwd"
}

test_symlinked_project_waits_for_worktree_move

echo "# all fm-spawn-symlink-wait tests passed"