diff --git a/.gitignore b/.gitignore index dc785fb8..23cd45a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ projects/ state/ data/ .no-mistakes/ +.omc/ .lavish/ .DS_Store .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e066fa8..3399d139 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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-/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 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index bd760060..8283c013 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -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/ 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 diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 21645fc3..6f23cdd7 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -35,7 +35,7 @@ You do not need to attach for routine supervision: `bin/fm-peek.sh fm-` 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-` 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 @@ -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. diff --git a/tests/fm-backend-autodetect-smoke.test.sh b/tests/fm-backend-autodetect-smoke.test.sh index f538ce75..3840b517 100755 --- a/tests/fm-backend-autodetect-smoke.test.sh +++ b/tests/fm-backend-autodetect-smoke.test.sh @@ -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" diff --git a/tests/fm-spawn-symlink-wait.test.sh b/tests/fm-spawn-symlink-wait.test.sh new file mode 100755 index 00000000..528ed34a --- /dev/null +++ b/tests/fm-spawn-symlink-wait.test.sh @@ -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/, 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"