From 64384f86e3006defffc2fbfb3ca43c4af0e1ed68 Mon Sep 17 00:00:00 2001 From: Johan Kartiwa <5590743+jkartiwa@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:30:16 -0700 Subject: [PATCH 1/7] fix(secondmate): give each cloned project its own treehouse worktree pool Two firstmate homes cloning the same project origin shared one treehouse worktree pool, because treehouse keys a pool on a hash of the origin URL alone. Every worktree in that pool stayed linked to whichever clone created it first, so a secondmate spawning work in a project its parent home also clones was handed a worktree of the PARENT's clone - and fm-spawn.sh's pre-registration guard correctly refused it, leaving the secondmate unable to dispatch that project at all. Point each freshly cloned project at its own pool instead, by writing a treehouse.toml with a `root` key into the clone at the moment it is cloned into a home, and excluding that generated file locally so the clone stays clean. Two clone paths exist - a secondmate seeded on this host, and one provisioned on a remote host - so both are covered. Fixes #4348. --- bin/fm-home-seed.sh | 3 ++ bin/fm-remote-home-provision.sh | 3 ++ bin/fm-treehouse-pool-lib.sh | 32 ++++++++++++ tests/fm-treehouse-pool-lib.test.sh | 77 +++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100755 bin/fm-treehouse-pool-lib.sh create mode 100755 tests/fm-treehouse-pool-lib.test.sh diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 6693ab1df74..1288c9e9137 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -49,6 +49,8 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" . "$SCRIPT_DIR/fm-secondmate-charter-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-treehouse-pool-lib.sh +. "$SCRIPT_DIR/fm-treehouse-pool-lib.sh" usage() { echo "usage: fm-home-seed.sh {...|--no-projects}" >&2 @@ -482,6 +484,7 @@ EOF fi url=$(source_origin_url "$project" "$mode" "$src") || return 1 git clone --quiet "$url" "$dst" + fm_treehouse_configure_pool_root "$dst" "$home" } validate_seed_project() { diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index 8f733d6d3c4..c837bfe643f 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -26,6 +26,8 @@ MAX_MANIFEST_BYTES=1048576 # shellcheck source=bin/fm-project-origin-lib.sh . "$SCRIPT_DIR/fm-project-origin-lib.sh" +# shellcheck source=bin/fm-treehouse-pool-lib.sh +. "$SCRIPT_DIR/fm-treehouse-pool-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } @@ -230,6 +232,7 @@ EOF else printf '%s\n' "$NAME" >> "$CREATED_PROJECTS" git clone --quiet -- "$ORIGIN" "$DEST" || die "could not clone project $NAME on the remote host" + fm_treehouse_configure_pool_root "$DEST" "$FM_HOME" || die "could not configure treehouse pool root for project $NAME" if [ "$MODE" = no-mistakes ]; then command -v no-mistakes >/dev/null 2>&1 || die "no-mistakes is unavailable for project $NAME" (cd "$DEST" && no-mistakes init >/dev/null && no-mistakes doctor >/dev/null) \ diff --git a/bin/fm-treehouse-pool-lib.sh b/bin/fm-treehouse-pool-lib.sh new file mode 100755 index 00000000000..6bf6febdd21 --- /dev/null +++ b/bin/fm-treehouse-pool-lib.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# fm-treehouse-pool-lib.sh - point a freshly cloned project's treehouse +# worktree pool at the home that cloned it. +# +# WHY: treehouse names a project's pool from a hash of the origin URL alone, +# placed under its configured root (default $HOME). Two firstmate homes that +# each clone the same origin therefore collide on one pool, and every +# worktree in it stays linked to whichever clone created it first - so a +# secondmate spawning work in a project its parent home also clones gets +# handed a worktree of the PARENT's clone. fm-spawn.sh's pre-registration +# guard (bin/fm-claude-trust.sh, bin/fm-agy-trust.sh) already catches and +# refuses that mismatch; this removes the collision instead of relaxing the +# guard. Treehouse reads treehouse.toml from a project's own repository root +# and honors a `root` key there ({root}/.treehouse/ instead of $HOME). + +# fm_treehouse_configure_pool_root +# Points 's treehouse pool at (an absolute path) and +# excludes the generated file locally so the clone stays clean. +fm_treehouse_configure_pool_root() { + local clone=$1 pool_root=$2 toml exclude + toml="$clone/treehouse.toml" + if git -C "$clone" ls-files --error-unmatch treehouse.toml >/dev/null 2>&1; then + echo "error: $clone tracks its own treehouse.toml; refusing to overwrite a project-owned config" >&2 + return 1 + fi + printf 'root = "%s"\n' "$pool_root" > "$toml.tmp.$$" + mv -f -- "$toml.tmp.$$" "$toml" + exclude="$clone/.git/info/exclude" + mkdir -p "$(dirname "$exclude")" + touch "$exclude" + grep -qxF '/treehouse.toml' "$exclude" 2>/dev/null || printf '/treehouse.toml\n' >> "$exclude" +} diff --git a/tests/fm-treehouse-pool-lib.test.sh b/tests/fm-treehouse-pool-lib.test.sh new file mode 100755 index 00000000000..c380b18781d --- /dev/null +++ b/tests/fm-treehouse-pool-lib.test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# tests/fm-treehouse-pool-lib.test.sh - proves fm_treehouse_configure_pool_root +# (bin/fm-treehouse-pool-lib.sh) actually gives two clones of one origin their +# own treehouse worktree pools, using the real treehouse binary end to end +# (harness-dependent check: the pool-collision behavior this fixes is +# treehouse's own, so a mocked path computation would only confirm the +# assumption already written into the mock). Skips when treehouse is absent. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found"; exit 0; } + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-treehouse-pool-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-treehouse-pool-lib) || fail "could not create temp root" + +# One throwaway origin, cloned twice - standing in for a project two separate +# firstmate homes have each cloned. +fm_git_init_commit "$TMP_ROOT/widget-origin" +fm_git_add_origin "$TMP_ROOT/widget-origin" "$TMP_ROOT/widget-origin.git" +git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-a/widget" || fail "could not clone home A's widget" +git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-b/widget" || fail "could not clone home B's widget" + +WT_A=; WT_B= +cleanup_leases() { + [ -z "$WT_A" ] || treehouse return --force "$WT_A" >/dev/null 2>&1 + [ -z "$WT_B" ] || treehouse return --force "$WT_B" >/dev/null 2>&1 + fm_test_cleanup +} +trap cleanup_leases EXIT + +fm_treehouse_configure_pool_root "$TMP_ROOT/home-a/widget" "$TMP_ROOT/home-a" \ + || fail "fm_treehouse_configure_pool_root refused for home A" +fm_treehouse_configure_pool_root "$TMP_ROOT/home-b/widget" "$TMP_ROOT/home-b" \ + || fail "fm_treehouse_configure_pool_root refused for home B" +pass "fm_treehouse_configure_pool_root accepts two fresh clones of one origin" + +[ -z "$(git -C "$TMP_ROOT/home-a/widget" status --porcelain)" ] \ + || fail "home A's clone reads as dirty after configuring its pool root" +[ -z "$(git -C "$TMP_ROOT/home-b/widget" status --porcelain)" ] \ + || fail "home B's clone reads as dirty after configuring its pool root" +pass "the generated treehouse.toml is excluded locally, so both clones stay clean" + +WT_A=$(cd "$TMP_ROOT/home-a/widget" && treehouse get --lease --lease-holder home-a) \ + || fail "treehouse get --lease failed for home A" +WT_B=$(cd "$TMP_ROOT/home-b/widget" && treehouse get --lease --lease-holder home-b) \ + || fail "treehouse get --lease failed for home B" +[ -n "$WT_A" ] && [ -n "$WT_B" ] || fail "treehouse get --lease did not report a worktree path" + +case "$WT_A" in + "$TMP_ROOT/home-a"/*) : ;; + *) fail "home A's worktree '$WT_A' did not land under home A's own pool root" ;; +esac +case "$WT_B" in + "$TMP_ROOT/home-b"/*) : ;; + *) fail "home B's worktree '$WT_B' did not land under home B's own pool root" ;; +esac +pass "each clone's real treehouse acquire lands in its own home's pool" + +GITDIR_A=$(cat "$WT_A/.git") +GITDIR_B=$(cat "$WT_B/.git") +case "$GITDIR_A" in + *"$TMP_ROOT/home-a/widget/.git/worktrees/"*) : ;; + *) fail "home A's pooled worktree links back to the wrong clone: $GITDIR_A" ;; +esac +case "$GITDIR_B" in + *"$TMP_ROOT/home-b/widget/.git/worktrees/"*) : ;; + *) fail "home B's pooled worktree links back to the wrong clone: $GITDIR_B" ;; +esac +pass "each pooled worktree is linked to its own clone, not the other home's" + +treehouse return --force "$WT_A" >/dev/null 2>&1 || fail "could not return home A's leased worktree" +treehouse return --force "$WT_B" >/dev/null 2>&1 || fail "could not return home B's leased worktree" +WT_A=; WT_B= From 1cfdb504d9db6d8ee7094d317adb59d0de67cd07 Mon Sep 17 00:00:00 2001 From: Johan Kartiwa <5590743+jkartiwa@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:51:01 -0700 Subject: [PATCH 2/7] no-mistakes(review): Relocate treehouse pool root; warn on project-owned config --- bin/fm-treehouse-pool-lib.sh | 39 +++++++++++--- tests/fm-treehouse-pool-lib.test.sh | 83 ++++++++++++++++++++++------- 2 files changed, 96 insertions(+), 26 deletions(-) diff --git a/bin/fm-treehouse-pool-lib.sh b/bin/fm-treehouse-pool-lib.sh index 6bf6febdd21..b1ed60ba8bb 100755 --- a/bin/fm-treehouse-pool-lib.sh +++ b/bin/fm-treehouse-pool-lib.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # fm-treehouse-pool-lib.sh - point a freshly cloned project's treehouse -# worktree pool at the home that cloned it. +# worktree pool at a root private to the home that cloned it. # # WHY: treehouse names a project's pool from a hash of the origin URL alone, # placed under its configured root (default $HOME). Two firstmate homes that @@ -12,17 +12,42 @@ # refuses that mismatch; this removes the collision instead of relaxing the # guard. Treehouse reads treehouse.toml from a project's own repository root # and honors a `root` key there ({root}/.treehouse/ instead of $HOME). +# +# The root is derived from the home's absolute path and lives in the machine +# state directory, outside the home worktree and every other git repository: +# treehouse keeps its pool out of git by appending the pool path to the +# .gitignore of whichever repository encloses {root}/.treehouse, so a root +# inside the home (itself a firstmate clone) would leave that home dirty and +# stop its fast-forward updates. + +# fm_treehouse_pool_root +# Prints the stable, collision-free pool root for . +fm_treehouse_pool_root() { + local home=$1 base hash + base="${XDG_STATE_HOME:-$HOME/.local/state}/firstmate/treehouse-pools" + if command -v shasum >/dev/null 2>&1; then + hash=$(printf '%s' "$home" | shasum -a 256 | awk '{print $1}') + elif command -v sha256sum >/dev/null 2>&1; then + hash=$(printf '%s' "$home" | sha256sum | awk '{print $1}') + else + hash=$(printf '%s' "$home" | cksum | awk '{printf "%08x%08x", $1, $2}') + fi + printf '%s/%s\n' "$base" "$hash" +} -# fm_treehouse_configure_pool_root -# Points 's treehouse pool at (an absolute path) and -# excludes the generated file locally so the clone stays clean. +# fm_treehouse_configure_pool_root +# Points 's treehouse pool at 's own pool root and excludes +# the generated file locally so the clone stays clean. A project that tracks +# its own treehouse.toml keeps it: the seed warns and continues instead of +# aborting. fm_treehouse_configure_pool_root() { - local clone=$1 pool_root=$2 toml exclude + local clone=$1 home=$2 toml exclude pool_root toml="$clone/treehouse.toml" if git -C "$clone" ls-files --error-unmatch treehouse.toml >/dev/null 2>&1; then - echo "error: $clone tracks its own treehouse.toml; refusing to overwrite a project-owned config" >&2 - return 1 + echo "warning: project $(basename "$clone") keeps its own treehouse pool configuration; its worker spawns will be refused until reconciled" >&2 + return 0 fi + pool_root=$(fm_treehouse_pool_root "$home") printf 'root = "%s"\n' "$pool_root" > "$toml.tmp.$$" mv -f -- "$toml.tmp.$$" "$toml" exclude="$clone/.git/info/exclude" diff --git a/tests/fm-treehouse-pool-lib.test.sh b/tests/fm-treehouse-pool-lib.test.sh index c380b18781d..88789feaf16 100755 --- a/tests/fm-treehouse-pool-lib.test.sh +++ b/tests/fm-treehouse-pool-lib.test.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # tests/fm-treehouse-pool-lib.test.sh - proves fm_treehouse_configure_pool_root # (bin/fm-treehouse-pool-lib.sh) actually gives two clones of one origin their -# own treehouse worktree pools, using the real treehouse binary end to end -# (harness-dependent check: the pool-collision behavior this fixes is -# treehouse's own, so a mocked path computation would only confirm the -# assumption already written into the mock). Skips when treehouse is absent. +# own treehouse worktree pools without dirtying either home, using the real +# treehouse binary end to end (harness-dependent check: the pool-collision +# behavior this fixes is treehouse's own, so a mocked path computation would +# only confirm the assumption already written into the mock). Skips when +# treehouse is absent. set -u # shellcheck source=tests/lib.sh @@ -16,13 +17,30 @@ command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found"; exit . "$ROOT/bin/fm-treehouse-pool-lib.sh" TMP_ROOT=$(fm_test_tmproot fm-treehouse-pool-lib) || fail "could not create temp root" +# Keep the derived pool roots inside the fixture instead of the developer's +# machine state directory. The fixture root is not a git repository, so a +# pool root here proves the pool can live outside every repository. +export XDG_STATE_HOME="$TMP_ROOT/state" + +# A realistic firstmate home: a real git repository that ignores its project +# clones, exactly as a seeded secondmate home does, so a pool root that dirties +# the home surfaces in its own `git status`. +make_home() { + local home=$1 + fm_git_init_commit "$home" + printf 'projects/\n' > "$home/.gitignore" + git -C "$home" add .gitignore + git -C "$home" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm gitignore +} # One throwaway origin, cloned twice - standing in for a project two separate # firstmate homes have each cloned. fm_git_init_commit "$TMP_ROOT/widget-origin" fm_git_add_origin "$TMP_ROOT/widget-origin" "$TMP_ROOT/widget-origin.git" -git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-a/widget" || fail "could not clone home A's widget" -git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-b/widget" || fail "could not clone home B's widget" +make_home "$TMP_ROOT/home-a" +make_home "$TMP_ROOT/home-b" +git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-a/projects/widget" || fail "could not clone home A's widget" +git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-b/projects/widget" || fail "could not clone home B's widget" WT_A=; WT_B= cleanup_leases() { @@ -32,46 +50,73 @@ cleanup_leases() { } trap cleanup_leases EXIT -fm_treehouse_configure_pool_root "$TMP_ROOT/home-a/widget" "$TMP_ROOT/home-a" \ +fm_treehouse_configure_pool_root "$TMP_ROOT/home-a/projects/widget" "$TMP_ROOT/home-a" \ || fail "fm_treehouse_configure_pool_root refused for home A" -fm_treehouse_configure_pool_root "$TMP_ROOT/home-b/widget" "$TMP_ROOT/home-b" \ +fm_treehouse_configure_pool_root "$TMP_ROOT/home-b/projects/widget" "$TMP_ROOT/home-b" \ || fail "fm_treehouse_configure_pool_root refused for home B" pass "fm_treehouse_configure_pool_root accepts two fresh clones of one origin" -[ -z "$(git -C "$TMP_ROOT/home-a/widget" status --porcelain)" ] \ +[ -z "$(git -C "$TMP_ROOT/home-a/projects/widget" status --porcelain)" ] \ || fail "home A's clone reads as dirty after configuring its pool root" -[ -z "$(git -C "$TMP_ROOT/home-b/widget" status --porcelain)" ] \ +[ -z "$(git -C "$TMP_ROOT/home-b/projects/widget" status --porcelain)" ] \ || fail "home B's clone reads as dirty after configuring its pool root" pass "the generated treehouse.toml is excluded locally, so both clones stay clean" -WT_A=$(cd "$TMP_ROOT/home-a/widget" && treehouse get --lease --lease-holder home-a) \ +# A project that tracks its own treehouse.toml keeps its own pool +# configuration; the seed must warn instead of aborting, and the file must be +# left untouched. +fm_git_init_commit "$TMP_ROOT/owned-origin" +printf 'root = "/srv/owned"\n' > "$TMP_ROOT/owned-origin/treehouse.toml" +git -C "$TMP_ROOT/owned-origin" add treehouse.toml +git -C "$TMP_ROOT/owned-origin" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm owned +fm_git_add_origin "$TMP_ROOT/owned-origin" "$TMP_ROOT/owned-origin.git" +git clone --quiet "$TMP_ROOT/owned-origin.git" "$TMP_ROOT/home-a/projects/owned" || fail "could not clone home A's owned project" +warning=$(fm_treehouse_configure_pool_root "$TMP_ROOT/home-a/projects/owned" "$TMP_ROOT/home-a" 2>&1) \ + || fail "fm_treehouse_configure_pool_root aborted the seed on a project-owned treehouse.toml" +[ "$(cat "$TMP_ROOT/home-a/projects/owned/treehouse.toml")" = 'root = "/srv/owned"' ] \ + || fail "fm_treehouse_configure_pool_root overwrote a project-owned treehouse.toml" +case "$warning" in + *owned*"refused until reconciled"*) : ;; + *) fail "fm_treehouse_configure_pool_root did not name the project whose pool config it left alone: $warning" ;; +esac +pass "a project-owned treehouse.toml is left untouched with a named warning, not a seed abort" + +WT_A=$(cd "$TMP_ROOT/home-a/projects/widget" && treehouse get --lease --lease-holder home-a) \ || fail "treehouse get --lease failed for home A" -WT_B=$(cd "$TMP_ROOT/home-b/widget" && treehouse get --lease --lease-holder home-b) \ +WT_B=$(cd "$TMP_ROOT/home-b/projects/widget" && treehouse get --lease --lease-holder home-b) \ || fail "treehouse get --lease failed for home B" [ -n "$WT_A" ] && [ -n "$WT_B" ] || fail "treehouse get --lease did not report a worktree path" case "$WT_A" in - "$TMP_ROOT/home-a"/*) : ;; - *) fail "home A's worktree '$WT_A' did not land under home A's own pool root" ;; + "$TMP_ROOT/home-a"/*) fail "home A's worktree '$WT_A' stayed inside home A's own worktree" ;; esac case "$WT_B" in - "$TMP_ROOT/home-b"/*) : ;; - *) fail "home B's worktree '$WT_B' did not land under home B's own pool root" ;; + "$TMP_ROOT/home-b"/*) fail "home B's worktree '$WT_B' stayed inside home B's own worktree" ;; esac -pass "each clone's real treehouse acquire lands in its own home's pool" +[ "$WT_A" != "$WT_B" ] || fail "both homes acquired the same pooled worktree '$WT_A'" +pass "each clone's real treehouse acquire lands in its own pool, outside its home" GITDIR_A=$(cat "$WT_A/.git") GITDIR_B=$(cat "$WT_B/.git") case "$GITDIR_A" in - *"$TMP_ROOT/home-a/widget/.git/worktrees/"*) : ;; + *"$TMP_ROOT/home-a/projects/widget/.git/worktrees/"*) : ;; *) fail "home A's pooled worktree links back to the wrong clone: $GITDIR_A" ;; esac case "$GITDIR_B" in - *"$TMP_ROOT/home-b/widget/.git/worktrees/"*) : ;; + *"$TMP_ROOT/home-b/projects/widget/.git/worktrees/"*) : ;; *) fail "home B's pooled worktree links back to the wrong clone: $GITDIR_B" ;; esac pass "each pooled worktree is linked to its own clone, not the other home's" +# The regression this fixes: treehouse keeps a pool out of git by rewriting the +# .gitignore of the repository enclosing {root}/.treehouse, so a root inside a +# home leaves that home permanently dirty and disables its fast-forward updates. +[ -z "$(git -C "$TMP_ROOT/home-a" status --porcelain)" ] \ + || fail "home A reads as dirty after an acquire" +[ -z "$(git -C "$TMP_ROOT/home-b" status --porcelain)" ] \ + || fail "home B reads as dirty after an acquire" +pass "neither home's own repository is dirtied by a project's pool acquire" + treehouse return --force "$WT_A" >/dev/null 2>&1 || fail "could not return home A's leased worktree" treehouse return --force "$WT_B" >/dev/null 2>&1 || fail "could not return home B's leased worktree" WT_A=; WT_B= From 6eb35a6ef88e772844e1791d38feac1977192770 Mon Sep 17 00:00:00 2001 From: Johan Kartiwa <5590743+jkartiwa@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:57:43 -0700 Subject: [PATCH 3/7] no-mistakes(review): Run treehouse pool test in real-herdr-gated CI lane --- bin/fm-test-run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index e69855f3875..3c9de2d123c 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -311,7 +311,7 @@ family_for_basename() { fm-backend-herdr-agent-exit-shell-e2e.test.sh|\ fm-herdr-attached-viewer-live-e2e.test.sh|fm-herdr-session-cleanup-e2e.test.sh|\ fm-backend-herdr-smoke.test.sh|fm-backend-herdr-workspace-per-home-e2e.test.sh|\ - fm-control-herdr-smoke.test.sh) + fm-control-herdr-smoke.test.sh|fm-treehouse-pool-lib.test.sh) printf '%s\n' real-herdr-gated ;; fm-backlog-handoff.test.sh|fm-on.test.sh|fm-remote-backlog-handoff.test.sh|\ From dc9c9eaa1af2aab61e8e7d687f8393e8d592f7dc Mon Sep 17 00:00:00 2001 From: Johan Kartiwa <5590743+jkartiwa@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:05:52 -0700 Subject: [PATCH 4/7] no-mistakes(review): Release first lease then assert distinct treehouse pools --- tests/fm-treehouse-pool-lib.test.sh | 45 ++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/fm-treehouse-pool-lib.test.sh b/tests/fm-treehouse-pool-lib.test.sh index 88789feaf16..fe85c2854ba 100755 --- a/tests/fm-treehouse-pool-lib.test.sh +++ b/tests/fm-treehouse-pool-lib.test.sh @@ -21,6 +21,10 @@ TMP_ROOT=$(fm_test_tmproot fm-treehouse-pool-lib) || fail "could not create temp # machine state directory. The fixture root is not a git repository, so a # pool root here proves the pool can live outside every repository. export XDG_STATE_HOME="$TMP_ROOT/state" +# Contain treehouse's own state, and any fallback to its default $HOME pool, in +# the fixture rather than the developer's home directory. +export HOME="$TMP_ROOT/user-home" +mkdir -p "$HOME" # A realistic firstmate home: a real git repository that ignores its project # clones, exactly as a seeded secondmate home does, so a pool root that dirties @@ -81,32 +85,46 @@ case "$warning" in esac pass "a project-owned treehouse.toml is left untouched with a named warning, not a seed abort" +ROOT_A=$(fm_treehouse_pool_root "$TMP_ROOT/home-a") +ROOT_B=$(fm_treehouse_pool_root "$TMP_ROOT/home-b") +[ "$ROOT_A" != "$ROOT_B" ] || fail "the two homes derived the same pool root '$ROOT_A'" + WT_A=$(cd "$TMP_ROOT/home-a/projects/widget" && treehouse get --lease --lease-holder home-a) \ || fail "treehouse get --lease failed for home A" -WT_B=$(cd "$TMP_ROOT/home-b/projects/widget" && treehouse get --lease --lease-holder home-b) \ - || fail "treehouse get --lease failed for home B" -[ -n "$WT_A" ] && [ -n "$WT_B" ] || fail "treehouse get --lease did not report a worktree path" - +[ -n "$WT_A" ] || fail "treehouse get --lease did not report a worktree path for home A" case "$WT_A" in - "$TMP_ROOT/home-a"/*) fail "home A's worktree '$WT_A' stayed inside home A's own worktree" ;; + "$ROOT_A"/.treehouse/*) : ;; + *) fail "home A's worktree '$WT_A' is not under home A's configured pool root '$ROOT_A'" ;; esac -case "$WT_B" in - "$TMP_ROOT/home-b"/*) fail "home B's worktree '$WT_B' stayed inside home B's own worktree" ;; -esac -[ "$WT_A" != "$WT_B" ] || fail "both homes acquired the same pooled worktree '$WT_A'" -pass "each clone's real treehouse acquire lands in its own pool, outside its home" - GITDIR_A=$(cat "$WT_A/.git") -GITDIR_B=$(cat "$WT_B/.git") case "$GITDIR_A" in *"$TMP_ROOT/home-a/projects/widget/.git/worktrees/"*) : ;; *) fail "home A's pooled worktree links back to the wrong clone: $GITDIR_A" ;; esac +POOL_A=$(dirname "$(dirname "$WT_A")") + +# Return A before B acquires: against a shared pool treehouse hands B the exact +# slot A just freed, which is the reuse path that would hand a secondmate a +# parent-owned worktree. Releasing A first makes the pool comparison below fail +# if the two clones still resolve to one pool. +treehouse return --force "$WT_A" >/dev/null 2>&1 || fail "could not return home A's leased worktree before home B acquires" +WT_A= + +WT_B=$(cd "$TMP_ROOT/home-b/projects/widget" && treehouse get --lease --lease-holder home-b) \ + || fail "treehouse get --lease failed for home B" +[ -n "$WT_B" ] || fail "treehouse get --lease did not report a worktree path for home B" +case "$WT_B" in + "$ROOT_B"/.treehouse/*) : ;; + *) fail "home B's worktree '$WT_B' is not under home B's configured pool root '$ROOT_B'" ;; +esac +GITDIR_B=$(cat "$WT_B/.git") case "$GITDIR_B" in *"$TMP_ROOT/home-b/projects/widget/.git/worktrees/"*) : ;; *) fail "home B's pooled worktree links back to the wrong clone: $GITDIR_B" ;; esac -pass "each pooled worktree is linked to its own clone, not the other home's" +POOL_B=$(dirname "$(dirname "$WT_B")") +[ "$POOL_A" != "$POOL_B" ] || fail "both homes resolved to the same treehouse pool '$POOL_A'" +pass "each clone acquires from its own pool, even after the first home returns its slot" # The regression this fixes: treehouse keeps a pool out of git by rewriting the # .gitignore of the repository enclosing {root}/.treehouse, so a root inside a @@ -117,6 +135,5 @@ pass "each pooled worktree is linked to its own clone, not the other home's" || fail "home B reads as dirty after an acquire" pass "neither home's own repository is dirtied by a project's pool acquire" -treehouse return --force "$WT_A" >/dev/null 2>&1 || fail "could not return home A's leased worktree" treehouse return --force "$WT_B" >/dev/null 2>&1 || fail "could not return home B's leased worktree" WT_A=; WT_B= From 4822311a9c15075f2fb595bd455be89ef8e8f040 Mon Sep 17 00:00:00 2001 From: Johan Kartiwa <5590743+jkartiwa@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:15:14 -0700 Subject: [PATCH 5/7] no-mistakes(document): Document per-home treehouse pool configuration for secondmate seeds --- .agents/skills/secondmate-provisioning/SKILL.md | 1 + bin/fm-home-seed.sh | 4 +++- bin/fm-remote-home-provision.sh | 4 +++- docs/configuration.md | 2 ++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 9d5a27e2eff..3b60fc9fa24 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -85,6 +85,7 @@ Release happens only on explicit retirement or seed rollback, never on routine r `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. It also writes the gitignored `.fm-secondmate-parent` durable binding before the required `.fm-secondmate-home` identity marker; the parser header in [`bin/fm-secondmate-parent-lib.sh`](../../../bin/fm-secondmate-parent-lib.sh) owns the record contract, and both files must remain in place. +Each project clone is also pointed at a treehouse pool root private to its home; [`bin/fm-treehouse-pool-lib.sh`](../../../bin/fm-treehouse-pool-lib.sh)'s header owns the derivation and what a project that already tracks its own `treehouse.toml` triggers. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 1288c9e9137..4ab8f54dbca 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -8,7 +8,9 @@ # leases the worktree under the secondmate so the home survives with # no live process and is never recycled until the lease is released with # "treehouse return". Projects are cloned -# from the active home into the secondmate home's projects/ directory. +# from the active home into the secondmate home's projects/ directory, and +# each clone is pointed at a treehouse pool private to this home (see +# bin/fm-treehouse-pool-lib.sh) so the two homes never share a pool. # That project list is non-exclusive provisioning data. Pass --no-projects # instead of a project list to seed a project-less home for a domain whose # subject is the firstmate repo itself; it is mutually exclusive with a diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index c837bfe643f..293ffb81533 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -14,7 +14,9 @@ # "remote" - read by bin/fm-teardown.sh's cleanup gate so a delegated public # reply promise, which the subsystem can only carry on the parent's own # filesystem, is never mistaken for one this child could hold - and the -# .fm-secondmate-home marker commits the complete seed last. +# .fm-secondmate-home marker commits the complete seed last. Each cloned project +# is also pointed at a treehouse pool private to this home through +# bin/fm-treehouse-pool-lib.sh, so a secondmate never shares its parent home's pool. # A newly created home is removed on failure. An existing matching seeded home # is converged only through guarded ordinary-file updates and new project clones. set -eu diff --git a/docs/configuration.md b/docs/configuration.md index d10624d2a77..7503ef763ed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -277,6 +277,8 @@ The lease is held under the secondmate id until explicit retirement or seed roll Teardown of a leased home fails closed if `treehouse return` cannot release the lease; plain-clone homes with no treehouse pool slot are removed directly. Secondmate routes cover `no-mistakes` and `direct-PR` projects; `local-only` projects remain main-firstmate work. For `no-mistakes` projects, seeding initializes only projects newly cloned into a secondmate home and refuses to mutate a preexisting clone that is not already initialized. +Every project cloned into a secondmate home, local or remote, is also pointed at a treehouse pool root private to that home, so a secondmate's workers lease worktrees linked to the secondmate's own clone rather than to the home the parent cloned from. +The generated `treehouse.toml` is excluded locally so the clone stays clean, and a project that already tracks its own `treehouse.toml` is left untouched with a named warning that its worker spawns will be refused until reconciled; [`bin/fm-treehouse-pool-lib.sh`](../bin/fm-treehouse-pool-lib.sh)'s header owns the pool-root derivation and that refusal. After creating a secondmate, move existing main-backlog queued items that you have judged in-scope with `fm-backlog-handoff.sh ...`; it refuses In flight, Done, or non-secondmate homes, and its [script header](../bin/fm-backlog-handoff.sh) owns route-specific wake outcomes and retries. Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled charter brief exists; set `FM_SECONDMATE_SCOPE` when the routing scope should differ from the charter text. The seeded home's `data/charter.md` owns the standard secondmate lifecycle and escalation contract; the route file points to it through the existing `home:` field instead of adding another pointer. From 8c08930fca28c2ca8ccb14416c0c7511adbcb743 Mon Sep 17 00:00:00 2001 From: Johan Kartiwa <5590743+jkartiwa@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:40:03 -0700 Subject: [PATCH 6/7] no-mistakes(review): Correct project-owned treehouse.toml warning to avoid false refusal claim --- bin/fm-treehouse-pool-lib.sh | 2 +- docs/configuration.md | 2 +- tests/fm-treehouse-pool-lib.test.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/fm-treehouse-pool-lib.sh b/bin/fm-treehouse-pool-lib.sh index b1ed60ba8bb..c19c3dda967 100755 --- a/bin/fm-treehouse-pool-lib.sh +++ b/bin/fm-treehouse-pool-lib.sh @@ -44,7 +44,7 @@ fm_treehouse_configure_pool_root() { local clone=$1 home=$2 toml exclude pool_root toml="$clone/treehouse.toml" if git -C "$clone" ls-files --error-unmatch treehouse.toml >/dev/null 2>&1; then - echo "warning: project $(basename "$clone") keeps its own treehouse pool configuration; its worker spawns will be refused until reconciled" >&2 + echo "warning: project $(basename "$clone") keeps its own treehouse pool configuration in treehouse.toml, so this home's per-home pool root was not applied; worker spawns from this home may be refused if that configuration resolves to a pool shared with another home. Give that configuration a root unique to this home to avoid the collision." >&2 return 0 fi pool_root=$(fm_treehouse_pool_root "$home") diff --git a/docs/configuration.md b/docs/configuration.md index 7503ef763ed..2f62fb13263 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -278,7 +278,7 @@ Teardown of a leased home fails closed if `treehouse return` cannot release the Secondmate routes cover `no-mistakes` and `direct-PR` projects; `local-only` projects remain main-firstmate work. For `no-mistakes` projects, seeding initializes only projects newly cloned into a secondmate home and refuses to mutate a preexisting clone that is not already initialized. Every project cloned into a secondmate home, local or remote, is also pointed at a treehouse pool root private to that home, so a secondmate's workers lease worktrees linked to the secondmate's own clone rather than to the home the parent cloned from. -The generated `treehouse.toml` is excluded locally so the clone stays clean, and a project that already tracks its own `treehouse.toml` is left untouched with a named warning that its worker spawns will be refused until reconciled; [`bin/fm-treehouse-pool-lib.sh`](../bin/fm-treehouse-pool-lib.sh)'s header owns the pool-root derivation and that refusal. +The generated `treehouse.toml` is excluded locally so the clone stays clean, and a project that already tracks its own `treehouse.toml` is left untouched with a named warning that this home's per-home pool root was not applied and that worker spawns from this home may be refused if that configuration resolves to a pool shared with another home; [`bin/fm-treehouse-pool-lib.sh`](../bin/fm-treehouse-pool-lib.sh)'s header owns the pool-root derivation. After creating a secondmate, move existing main-backlog queued items that you have judged in-scope with `fm-backlog-handoff.sh ...`; it refuses In flight, Done, or non-secondmate homes, and its [script header](../bin/fm-backlog-handoff.sh) owns route-specific wake outcomes and retries. Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled charter brief exists; set `FM_SECONDMATE_SCOPE` when the routing scope should differ from the charter text. The seeded home's `data/charter.md` owns the standard secondmate lifecycle and escalation contract; the route file points to it through the existing `home:` field instead of adding another pointer. diff --git a/tests/fm-treehouse-pool-lib.test.sh b/tests/fm-treehouse-pool-lib.test.sh index fe85c2854ba..2a5d065e7e8 100755 --- a/tests/fm-treehouse-pool-lib.test.sh +++ b/tests/fm-treehouse-pool-lib.test.sh @@ -80,8 +80,8 @@ warning=$(fm_treehouse_configure_pool_root "$TMP_ROOT/home-a/projects/owned" "$T [ "$(cat "$TMP_ROOT/home-a/projects/owned/treehouse.toml")" = 'root = "/srv/owned"' ] \ || fail "fm_treehouse_configure_pool_root overwrote a project-owned treehouse.toml" case "$warning" in - *owned*"refused until reconciled"*) : ;; - *) fail "fm_treehouse_configure_pool_root did not name the project whose pool config it left alone: $warning" ;; + *"owned"*"treehouse.toml"*"per-home pool root was not applied"*"may be refused"*) : ;; + *) fail "fm_treehouse_configure_pool_root did not name the project and file whose pool config it left alone: $warning" ;; esac pass "a project-owned treehouse.toml is left untouched with a named warning, not a seed abort" From a6532cd270ea8e3b885f717e620b7a5534368e8e Mon Sep 17 00:00:00 2001 From: Johan Kartiwa <5590743+jkartiwa@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:52:31 -0700 Subject: [PATCH 7/7] no-mistakes(document): Correct overbroad treehouse pool-sharing claims in seed docs --- .agents/skills/secondmate-provisioning/SKILL.md | 2 +- bin/fm-home-seed.sh | 5 +++-- bin/fm-remote-home-provision.sh | 7 ++++--- docs/configuration.md | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 3b60fc9fa24..cc123ffc6a5 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -85,7 +85,7 @@ Release happens only on explicit retirement or seed rollback, never on routine r `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. It also writes the gitignored `.fm-secondmate-parent` durable binding before the required `.fm-secondmate-home` identity marker; the parser header in [`bin/fm-secondmate-parent-lib.sh`](../../../bin/fm-secondmate-parent-lib.sh) owns the record contract, and both files must remain in place. -Each project clone is also pointed at a treehouse pool root private to its home; [`bin/fm-treehouse-pool-lib.sh`](../../../bin/fm-treehouse-pool-lib.sh)'s header owns the derivation and what a project that already tracks its own `treehouse.toml` triggers. +Each newly cloned project is also pointed at a treehouse pool root private to its home; [`bin/fm-treehouse-pool-lib.sh`](../../../bin/fm-treehouse-pool-lib.sh)'s header owns the derivation and what a project that already tracks its own `treehouse.toml` triggers. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 4ab8f54dbca..658c3269115 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -9,8 +9,9 @@ # no live process and is never recycled until the lease is released with # "treehouse return". Projects are cloned # from the active home into the secondmate home's projects/ directory, and -# each clone is pointed at a treehouse pool private to this home (see -# bin/fm-treehouse-pool-lib.sh) so the two homes never share a pool. +# each new clone is pointed at a treehouse pool private to this home (see +# bin/fm-treehouse-pool-lib.sh, which owns the derivation and the +# project-owned treehouse.toml case). # That project list is non-exclusive provisioning data. Pass --no-projects # instead of a project list to seed a project-less home for a domain whose # subject is the firstmate repo itself; it is mutually exclusive with a diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index 293ffb81533..1555889f1ea 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -14,9 +14,10 @@ # "remote" - read by bin/fm-teardown.sh's cleanup gate so a delegated public # reply promise, which the subsystem can only carry on the parent's own # filesystem, is never mistaken for one this child could hold - and the -# .fm-secondmate-home marker commits the complete seed last. Each cloned project -# is also pointed at a treehouse pool private to this home through -# bin/fm-treehouse-pool-lib.sh, so a secondmate never shares its parent home's pool. +# .fm-secondmate-home marker commits the complete seed last. Each newly cloned +# project is also pointed at a treehouse pool private to this home through +# bin/fm-treehouse-pool-lib.sh, which owns the derivation and the project-owned +# treehouse.toml case. # A newly created home is removed on failure. An existing matching seeded home # is converged only through guarded ordinary-file updates and new project clones. set -eu diff --git a/docs/configuration.md b/docs/configuration.md index 2f62fb13263..9b955a5ec25 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -277,7 +277,7 @@ The lease is held under the secondmate id until explicit retirement or seed roll Teardown of a leased home fails closed if `treehouse return` cannot release the lease; plain-clone homes with no treehouse pool slot are removed directly. Secondmate routes cover `no-mistakes` and `direct-PR` projects; `local-only` projects remain main-firstmate work. For `no-mistakes` projects, seeding initializes only projects newly cloned into a secondmate home and refuses to mutate a preexisting clone that is not already initialized. -Every project cloned into a secondmate home, local or remote, is also pointed at a treehouse pool root private to that home, so a secondmate's workers lease worktrees linked to the secondmate's own clone rather than to the home the parent cloned from. +A project cloned into a secondmate home, local or remote, is also pointed at a treehouse pool root private to that home, so a secondmate's workers lease worktrees linked to the secondmate's own clone rather than to the home the parent cloned from. The generated `treehouse.toml` is excluded locally so the clone stays clean, and a project that already tracks its own `treehouse.toml` is left untouched with a named warning that this home's per-home pool root was not applied and that worker spawns from this home may be refused if that configuration resolves to a pool shared with another home; [`bin/fm-treehouse-pool-lib.sh`](../bin/fm-treehouse-pool-lib.sh)'s header owns the pool-root derivation. After creating a secondmate, move existing main-backlog queued items that you have judged in-scope with `fm-backlog-handoff.sh ...`; it refuses In flight, Done, or non-secondmate homes, and its [script header](../bin/fm-backlog-handoff.sh) owns route-specific wake outcomes and retries. Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled charter brief exists; set `FM_SECONDMATE_SCOPE` when the routing scope should differ from the charter text.