diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 9d5a27e2eff..cc123ffc6a5 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 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 6693ab1df74..658c3269115 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -8,7 +8,10 @@ # 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 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 @@ -49,6 +52,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 +487,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..1555889f1ea 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -14,7 +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. +# .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 @@ -26,6 +29,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 +235,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-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|\ diff --git a/bin/fm-treehouse-pool-lib.sh b/bin/fm-treehouse-pool-lib.sh new file mode 100755 index 00000000000..c19c3dda967 --- /dev/null +++ b/bin/fm-treehouse-pool-lib.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# fm-treehouse-pool-lib.sh - point a freshly cloned project's treehouse +# 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 +# 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). +# +# 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 '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 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 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") + 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/docs/configuration.md b/docs/configuration.md index d10624d2a77..9b955a5ec25 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. +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. 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 new file mode 100755 index 00000000000..2a5d065e7e8 --- /dev/null +++ b/tests/fm-treehouse-pool-lib.test.sh @@ -0,0 +1,139 @@ +#!/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 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 +. "$(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" +# 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" +# 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 +# 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" +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() { + [ -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/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/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/projects/widget" status --porcelain)" ] \ + || fail "home A's clone reads as dirty after configuring its pool root" +[ -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" + +# 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"*"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" + +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" +[ -n "$WT_A" ] || fail "treehouse get --lease did not report a worktree path for home A" +case "$WT_A" in + "$ROOT_A"/.treehouse/*) : ;; + *) fail "home A's worktree '$WT_A' is not under home A's configured pool root '$ROOT_A'" ;; +esac +GITDIR_A=$(cat "$WT_A/.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 +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 +# 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_B" >/dev/null 2>&1 || fail "could not return home B's leased worktree" +WT_A=; WT_B=