fix(bin): pool task worktrees per home and refuse a worktree of another clone - #4222
Alberto-Codes wants to merge 3 commits into
Conversation
…er clone Treehouse keys a pool by the clone's directory basename plus the first six sha256 hex digits of its origin URL, under <root>/.treehouse/ with the root defaulting to $HOME, never by the clone the pool was created from. Verified against treehouse v2.3.0: two same-named clones of one origin under one root resolve to one pool, and the second clone is handed the first clone's worktree. Each secondmate home clones its projects under the same names and origins as the main home, so a secondmate spawn was handed a worktree of the main home's clone and vice versa. Only Claude's trust pre-registration ever refused that shape, and only for claude; a Cursor spawn launched straight into the other home's clone. Two changes, both in the spawn path: - fm-spawn now sends `treehouse get --root '<home>'` into the task pane, the home resolved from FM_HOME by fm-wake-lib's fm_treehouse_pool_root as the home's physical path, so every home draws worktrees only from its own clones. The root travels as literal command text because an exported TREEHOUSE_ROOT never reaches the pane's own shell. Teardown needs no root: treehouse return locates the pool from the slot path, so in-flight slots under the old shared root still return. - The worktree isolation predicate additionally requires the worktree's git common dir to be the spawning project's, on every backend Treehouse serves and independent of harness, at discovery, validation, and relaunch. A pane settled on a worktree of another clone is refused at the settle deadline naming both clones and publishes no task. Orca owns its own worktree shape and is exempt rather than guessed at. The Claude trust step is unchanged. tests/fm-spawn-pool-home.test.sh drives the real spawn with a fake terminal: the refusal case reproduces the incident shape and passes only with the guard (against the unpatched script the same fixture "spawned" into the other clone's worktree), the own-clone case launches, and the recorded pane text carries the physical home as --root, through a symlinked FM_HOME and a path containing a single quote. tests/fm-treehouse-pool-root.test.sh pins the pool key, the collision, the per-home separation, and rootless return against the installed treehouse binary, so the facts the fix rests on fail loudly if treehouse changes them; the portable CI lanes now install the pinned binary so that suite cannot merely skip there. The dated evidence is recorded under docs/verification/runtime-backends.md "Treehouse pool root". Existing pools under ~/.treehouse are left in place: in-flight tasks tear down unchanged, and idle slots there are no longer handed out and can be destroyed from their owning clone once nothing runs in them.
There was a problem hiding this comment.
🟡 Changes recommended
There are two correctness/robustness issues in the changed code/tests (a library helper that can mutate caller cwd, and a regression test that can’t detect broken quoting) that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Captain, this PR fixes cross-home Treehouse worktree pool collisions by making task worktree allocation pool per FM_HOME (via treehouse get --root <home>) and adding a backend-independent guard that refuses a worktree belonging to a different clone before any harness launches.
Changes:
- Route Treehouse pooling through a per-home root and hard-refuse “worktree of another clone” during spawn isolation checks.
- Bump CI’s pinned Treehouse version to support
--root, and improve bootstrap diagnostics to name missing Treehouse flags. - Add and wire regression tests that pin Treehouse pool-key/root facts and verify per-home pooling behavior.
File summaries
| File | Description |
|---|---|
| tests/fm-treehouse-pool-root.test.sh | New integration test that pins Treehouse pool-key/root/return behavior against the installed binary. |
| tests/fm-tangle-guard.test.sh | Updates spawn send-keys assertion to require treehouse get --root '<physical home>'. |
| tests/fm-startup-memory-budget.test.sh | Updates Treehouse fake help output to advertise global --root. |
| tests/fm-spawn-pool-home.test.sh | New spawn regression tests for per-home pool rooting and foreign-clone worktree refusal. |
| tests/fm-session-start.test.sh | Updates Treehouse fake help output to include global --root. |
| tests/fm-secondmate-sync.test.sh | Updates Treehouse help fakes and bootstrap flag fake plumbing (FM_FAKE_TREEHOUSE_GET_FLAGS). |
| tests/fm-secondmate-liveness.test.sh | Updates Treehouse fake help output to include global --root. |
| tests/fm-secondmate-harness.test.sh | Updates Treehouse fake help output to include global --root. |
| tests/fm-bootstrap.test.sh | Enhances bootstrap suite to model --lease and --root capability separately and assert missing-flag messaging. |
| tests/fm-bootstrap-network-parallel.test.sh | Updates bootstrap network-parallel fixture to advertise both Treehouse flags. |
| docs/verification/runtime-backends.md | Documents and evidences Treehouse pool-root/key behavior and the --root version floor. |
| docs/scripts.md | Notes Treehouse installer is used for pool-root regression coverage in CI. |
| docs/configuration.md | Documents .treehouse/ and clarifies FM_HOME as the Treehouse pool root. |
| docs/architecture.md | Updates worktree isolation doc to include per-home pooling and foreign-clone guard plus tests. |
| bin/fm-wake-lib.sh | Adds fm_treehouse_pool_root helper for resolving a home’s physical path for pooling. |
| bin/fm-test-run.sh | Registers new tests into families/lanes. |
| bin/fm-spawn.sh | Sends treehouse get --root '<home>' to panes and adds clone-membership verification in worktree isolation. |
| bin/fm-install-treehouse.sh | Bumps CI pin to Treehouse v2.3.0 and updates checksums/docs. |
| bin/fm-bootstrap.sh | Replaces lease-only probe with treehouse_missing_flag for --lease and --root, reporting the missing flag. |
| AGENTS.md | Documents .treehouse/ as a home-local pool directory. |
| .gitignore | Ignores .treehouse/. |
| .github/workflows/ci.yml | Installs pinned Treehouse for the portable serial job and fails CI if the “treehouse not found” gate-skip occurs. |
| .agents/skills/stuck-crewmate-recovery/SKILL.md | Updates guidance to use treehouse status --root <home> for per-home pools. |
| .agents/skills/bootstrap-diagnostics/SKILL.md | Clarifies Treehouse “MISSING” may mean an installed build lacks a required flag (named in the message). |
Review details
- Files reviewed: 18/24 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| expected="treehouse get --root '${home_phys//\'/\'\\\'\'}' Enter" | ||
|
|
||
| out=$(run_pool_spawn "$id" "$OWN_WT") | ||
| status=$? | ||
| expect_code 0 "$status" "spawn from a home path containing a quote should succeed"$'\n'"$out" | ||
| grep -F -- "$expected" "$REC" >/dev/null \ | ||
| || fail "the pane did not receive a correctly quoted pool root; expected: $expected"$'\n'"$(cat "$REC")" |
|
Speaking as Kun's firstmate: first look on fork PR #4222 (Alberto-Codes). Head Attestation: MATCH (body Contract-class: restore. Unconfigured spawn already promised home-owned clones / isolated worker copies; shared Treehouse identity-keyed pool handed secondmates a worktree of another clone (observed Cursor near-miss). Per-home VISION.md per-rule
CI: First-time fork workflows approved this pass → CI |
Intent
The captain's word on 2026-09-11, choosing this from the queued work: "go." He picked it over several other firstmate defects because real work is stacked behind it, and because the near-miss below is worse than an inconvenience.
WHAT IS BROKEN. Each persistent second mate gets its own clone of a project. Treehouse keys its worktree pool by repository IDENTITY, not by clone path or by home. Both clones carry the same origin URL, so a spawn from a second mate's home is handed a worktree of the MAIN home's clone. The second mate's dispatch is blocked for every item in its domain as a result.
WHY IT IS URGENT RATHER THAN ANNOYING. The only place the worktree-to-clone relationship is checked anywhere in the spawn path is Claude's trust pre-registration, which refuses because the worktree is not of the clone that was asked for. That refusal is an accident of one harness's trust step, not a guard. A Cursor spawn SAILS PAST IT AND LAUNCHES INTO THE WRONG CLONE. Observed cleanly on 2026-09-06: a second mate's Cursor spawn launched into the main home's clone, was caught by the mate itself rather than by any check, and left an empty branch in the main home's clone plus a task stuck in flight. Nothing was lost only because the mate was watching. A worker that actually committed would have written into another home's clone silently.
WHY IT IS OURS AND NOT TREEHOUSE'S. Treehouse does what it documents. Firstmate is the side that decided each home owns a clone while calling a name-keyed shared pool with no home in the key.
TWO CORRECTIONS THAT COST TIME ALREADY, recorded on 2026-09-06 after a proposed interim was tested and failed. Do not repeat either.
FIRST: TREEHOUSE_ROOT IS NOT THE ANSWER AND MAY NOT EVEN BE REACHABLE. bin/fm-spawn.sh sends the literal text
treehouse getINTO the spawned pane, so the pool resolves in that pane's environment and an exported TREEHOUSE_ROOT never reaches it. And the root would not have helped regardless, because the pool key is repository identity rather than path: a different root yields a different EMPTY directory, not a different pool.SECOND, AND THIS IS THE METHODOLOGICAL WARNING: firstmate's own verification of that interim was invalid. It observed an empty pool under a fresh root and concluded the pool had moved - but an empty directory and a relocated pool look identical to that check. Do not accept a check that cannot distinguish the outcome you want from the outcome you fear.
THE INTERIM CURRENTLY IN PLACE is that the second mate was told to prefix its spawns by hand. That is what this task exists to replace.
What Changed
bin/fm-spawn.shnow sendstreehouse get --root '<home>'into the task pane instead of a baretreehouse get, with the root resolved fromFM_HOMEby a newfm_treehouse_pool_roothelper inbin/fm-wake-lib.sh, so each home draws task worktrees from its own<home>/.treehouse/pool rather than a$HOME-rooted pool keyed only by clone basename and origin hash; the root travels as literal command text because an exportedTREEHOUSE_ROOTnever reaches the pane, and an unresolvable home aborts the spawn.spawn_worktree_isolatedadditionally compares the candidate worktree's git common dir against the spawning project's on every non-Orca backend, refusing a worktree of another clone before any harness launches, andvalidate_spawn_worktreenow prints the specificSPAWN_WT_REASONin its refusal.bin/fm-install-treehouse.shbumps the CI pin from v2.0.1 to v2.3.0 (new per-platform SHA-256s) for--rootsupport,bin/fm-bootstrap.shreplaces its--lease-only probe withtreehouse_missing_flagthat probes--leaseand--rootand names the missing flag in theMISSING:line, CI installs the pinned Treehouse for the portable-serial shards with--fail-on-gate-skip 'treehouse not found', and.treehouse/is gitignored.tests/fm-spawn-pool-home.test.sh(per-home pool root and clone guard) andtests/fm-treehouse-pool-root.test.sh(pool-key and--root/returnfacts against the real binary), registers both inbin/fm-test-run.shlanes, updates existing fakes to advertise--rootunder Global Flags viaFM_FAKE_TREEHOUSE_GET_FLAGS, and refreshes the architecture, configuration, scripts, AGENTS, skill, and runtime-backends verification docs.🤖 Generated with Claude Code
Risk Assessment
Testing
Reproduced the reported defect end-to-end and then showed it fixed, driving the real fm-spawn.sh and the real treehouse v2.3.0 binary through the two-homes/one-origin shape: on the base commit the second mate's spawn launched into the main home's clone and published a task, while on the change each pane is senttreehouse get --root '<its own home>', each home pools under itself, each mate lands in its own clone, and rootlesstreehouse returnstill releases the slot; with the root sabotaged the new clone-membership guard refuses before any harness launches. The Treehouse pin bump was measured rather than inferred - v2.0.1 rejects--rooton both get and status, v2.3.0 advertises it - and the real bootstrap names the missing flag for a genuine v2.0.1 build. Both new suites fail on the base tree and pass on the change, the full bootstrap suite and seven adjacent touched suites pass, and CI lane wiring was checked semantically so neither new test can silently skip. No UI surface is involved (all CLI and shell), so evidence is CLI transcripts rather than screenshots. One unrelated herdr liveness case fails identically on the base commit and is reported as informational only.Evidence: End-to-end: second mate dispatched into the main home's clone, before and after (real fm-spawn + real treehouse)
Source: End-to-end: second mate dispatched into the main home's clone, before and after (real fm-spawn + real treehouse)
Evidence: Treehouse --root capability measured on v2.0.1 vs v2.3.0, and the bootstrap line an operator sees with each real binary
Source: Treehouse --root capability measured on v2.0.1 vs v2.3.0, and the bootstrap line an operator sees with each real binary
Evidence: Regression direction: the two new suites on the base tree vs the fix
Source: Regression direction: the two new suites on the base tree vs the fix
Evidence: The defect and the fix, side by side (excerpt)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
tests/fm-spawn-pool-home.test.sh:176- test_pool_root_quoting_survives_a_single_quote cannot fail for the bug it claims to guard. The expected string is built with${home_phys//\'/\'\\\'\'}- character for character the same parameter expansion bin/fm-spawn.sh:3115 uses to build the text it sends. The test therefore asserts expand(home) == expand(home), which holds no matter what escaping scheme the implementation uses; replacing the implementation's'\''with a broken\'would leave the test green. This is the exact methodological trap the intent warns about ("Do not accept a check that cannot distinguish the outcome you want from the outcome you fear"). Fix: derive the expectation independently of the implementation's expression - extract the recorded send-keys text from $REC and run it through a shell (e.g.eval "set -- ${text%% Enter}"or a faketreehouseon PATH that records "$@"), then assert the resulting --root argument string-equals "$home_phys"..github/workflows/ci.yml:82- The new CI steps wire tests/fm-treehouse-pool-root.test.sh into all three portable lane jobs via bin/fm-install-treehouse.sh, which pins Treehouse v2.0.1 (bin/fm-install-treehouse.sh:15) and asserts that exact pin post-install. But every fact the test pins - global--rootongetandstatus, the pool key<basename>-<sha256(origin)[:6]>under<root>/.treehouse/, the shared-root collision, and rootlessreturn- was verified against v2.3.0 (docs/verification/runtime-backends.md:1497), three minor versions newer, and v2.3.0 is what is installed on the operating machine. If v2.0.1 lacks the global--rootflag or uses a different key shape the suite fails outright; if it merely differs in a detail the suite passes while proving nothing about the version the fleet actually runs, which is the shape of check the intent explicitly warns against. The remedy - bumping the pinned third-party version and its four platform checksums, which also changes what the real-Herdr lane exercises - extends the change beyond its stated intent, so it needs your call: bump the pin to v2.3.0, or record in the verification section why v2.0.1 is equivalent for these four facts.bin/fm-bootstrap.sh:910- Every ship/scout spawn now depends on Treehouse's global--rootflag, but the only capability probe is treehouse_supports_lease, which grepstreehouse get --helpfor--leasealone. An installed Treehouse that has--leasebut not--rootpasses bootstrap as healthy, then fails inside the pane:treehouse get --root '<home>'errors, the pane's shell never leaves the project, and the poll at bin/fm-spawn.sh:3153 burns the full 60s before refusing with "treehouse get did not enter an isolated worktree within 60s" - a message that never names the flag, so the operator has no path from the symptom to "upgrade treehouse". The smallest honest remedy adds a new capability probe and MISSING/upgrade branch alongside the lease probe, which extends this change with a new gate rather than correcting what it does, so it needs your authorization; the alternative is to confirm the--rootflag predates the repo's supported Treehouse floor and leave the probe alone..github/workflows/ci.yml:90- Simplification: neither new test lands in the parallel lanes.bin/fm-test-run.sh --list --lane portable-parallel-1|-2contains neither fm-spawn-pool-home.test.sh nor fm-treehouse-pool-root.test.sh - both resolve to portable-serial - and no other test in those two lanes emits a "treehouse not found" skip (the four tests that do are all family real-herdr-gated). So the "Install pinned Treehouse" step and the--fail-on-gate-skip 'treehouse not found'flag added to the shard-1 and shard-2 jobs are not required by the intent: they download and checksum a release on two jobs that never invoke it, and guard a skip string those lanes cannot produce. Recommend removing both from the parallel shard jobs and keeping them only on the portable serial job (ci.yml:206/228). If they are deliberate insurance against the lane rebalancing this repo does periodically, say so in the step comment instead.bin/fm-home-seed.sh:396- Sibling-path note, not a defect in this change: acquire_treehouse_home still runstreehouse get --leasewith no --root, so firstmate HOME leases keep drawing from the default $HOME pool keyed by basename+origin. Within the fleet model this is safe - a treehouse-leased home is a linked worktree of the same root clone (same common dir), and a git-cloned secondmate home takes the parent's path as its origin, so both yield either the correct clone or a distinct pool key. The shared-pool shape would only bite if one machine ever held two separate firstmate clones of the same origin, which is outside the stated intent (task worktrees for second-mate dispatch). Recorded so the scoping is explicit, not as work to do.🔧 Fix: bump pinned Treehouse to v2.3.0 and probe --root support
3 issues (2 warnings, 1 info) still open:
bin/fm-spawn.sh:3116- Intent conformance: the change's fix IS the Treehouse root, which the intent's first correction marks as a mistake not to repeat. Quoting the criterion: "FIRST: TREEHOUSE_ROOT IS NOT THE ANSWER AND MAY NOT EVEN BE REACHABLE. ... And the root would not have helped regardless, because the pool key is repository identity rather than path: a different root yields a different EMPTY directory, not a different pool." The contradicting hunk isspawn_send_text_line "$WT_TARGET" "treehouse get --root '$spawn_pool_root_quoted'"plusfm_treehouse_pool_root(bin/fm-wake-lib.sh:1194). The change does satisfy the reachability half honestly - the root travels as literal pane text, not as an exported TREEHOUSE_ROOT - and it disproves the second half with exactly the evidence standard the intent's SECOND correction demanded: docs/verification/runtime-backends.md and tests/fm-treehouse-pool-root.test.sh read the git common dir of the worktree actually handed out (clone B under root B gets COMMON_B; under the shared root it gets COMMON_A), never whether a directory is empty. I independently confirmed the premise the correction rested on is false for the installed binary: the pool key is<clone basename>-<sha256(origin)[:6]>(verified: this repo's origin hashes to b8697d, matching the live ~/.treehouse/firstmate-b8697d pool), so a distinct root yields a distinct pool whose first slot is created from the invoking clone. This is a well-evidenced reversal, not a repeat of the earlier mistake - but it reverses a correction the captain wrote down, so it needs their explicit acceptance rather than a reviewer's.bin/fm-wake-lib.sh:1197- fm_treehouse_pool_root runsCDPATH='' cd -- "$home" 2>/dev/null && pwd -Pdirectly in the caller's shell, so calling it outside a command substitution silently moves the caller's cwd (and OLDPWD). The sole production caller, bin/fm-spawn.sh:3111, usesSPAWN_POOL_ROOT=$(fm_treehouse_pool_root "$FM_HOME"), so nothing breaks today - but this is a helper in a library sourced by fm-spawn, fm-teardown, hooks, and the wake path, and every other path-resolving function in the same file wraps itscdin a subshell (lines 1160, 1173, 1215, 1222). Failure scenario: a later caller writesif fm_treehouse_pool_root >/dev/null; then ...and fm-spawn.sh silently continues from inside the home instead of its original cwd, changing what every subsequent relativegit -C/path resolution sees. Remedy is mechanical and behavior-preserving:( CDPATH='' cd -- "$home" 2>/dev/null && pwd -P ).bin/fm-teardown.sh:3226- The comment above the task-worktree return still reads "treehouse resolves the pool from the working directory, so run it from the project." That is no longer the mechanism teardown depends on: with the per-home root, the slot lives under <home>/.treehouse/<key>/<n>/<repo> while the project clone's default root is $HOME, so a working-directory-derived pool lookup would miss it. What actually makes the rootlesstreehouse returnwork is that treehouse resolves the pool from the SLOT PATH - the fact bin/fm-spawn.sh:203 asserts and tests/fm-treehouse-pool-root.test.sh's test_return_needs_no_root pins against the real binary. Failure scenario: a maintainer reading this comment concludes the cd is what locates the pool and either drops it or adds a--root "$HOME", breaking return for every slot under a per-home root. Update the comment to name slot-path resolution and point at the pinning test.tests/fm-secondmate-liveness.test.sh:165- tests/fm-secondmate-liveness.test.sh fails on this machine at the case 'Herdr pane state unknown should map to unreadable, got missing'. Pre-existing and unrelated to this change: it fails identically on the base commit e0d269e, and the case stubs only fm_backend_herdr_pane_agent_state, so the 'unknown' branch falls through to fm_backend_herdr_server_running_state, which probes the ambient herdr server (0.8.2 installed, not running) and yields 'missing'. The change touched this file only to rename FM_FAKE_TREEHOUSE_LEASE_HELP to FM_FAKE_TREEHOUSE_GET_FLAGS. No action needed for this change; remote CI owns it.bash tests/fm-spawn-pool-home.test.sh- 4/4 ok on the change; on a base-commit tree with the same test file it fails withspawned pool-home-foreign-k1 ... worktree=.../pool-other/proj(launched into another clone)bash tests/fm-treehouse-pool-root.test.sh- 4/4 ok against the real installed treehouse v2.3.0 (no stubs); on the base tree it fails atfm_treehouse_pool_root: command not foundbash tests/fm-bootstrap.test.sh- full suite passes, including the new rows 'treehouse without --lease reports an upgrade naming that flag' and 'treehouse without --root reports an upgrade naming that flag'Manual end-to-end: two firstmate homes on one $HOME, each with its own clone of one origin at the same basename; fake tmux records the pane text and actually EXECUTES thetreehouse get ...line against the real binary, reporting the leased worktree back as the pane cwd. Run three ways - base fm-spawn.sh (reproduces the defect), fixed fm-spawn.sh (each mate in its own clone), fixed fm-spawn.sh with the pane's --root dropped (guard refuses, no task metadata published)Manual measurement: installed treehouse v2.0.1 and v2.3.0 to temp dirs via bin/fm-install-treehouse.sh (base and target pins; installer asserts sha256 and version).treehouse get --help,treehouse get --root /tmp,treehouse status --root /tmpon v2.0.1 ->unknown flag: --root; v2.3.0 advertises--rootunder Global FlagsManual: ran the realbin/fm-bootstrap.shwith each genuine treehouse binary on PATH (rest of toolchain faked from the bootstrap suite's own helpers) - v2.0.1 yieldsMISSING: treehouse (installed build's 'treehouse get' lacks --root; install: ...), v2.3.0 is silentgit check-ignore -v .treehouse/proj-abc/1/proj- git itself confirms the new .gitignore rule covers a per-home pool inside a firstmate home checkoutbin/fm-test-run.sh --list --lane portable-serial|portable-parallel-1|portable-parallel-2plus a YAML parse of .github/workflows/ci.yml - both new tests resolve to portable-serial, the only lane carrying the Install pinned Treehouse step and the--fail-on-gate-skip 'treehouse not found'flagAdjacent touched suites:bash tests/fm-spawn-worktree-settle.test.sh,tests/fm-tangle-guard.test.sh,tests/fm-secondmate-sync.test.sh,tests/fm-session-start.test.sh,tests/fm-startup-memory-budget.test.sh,tests/fm-secondmate-harness.test.sh,tests/fm-bootstrap-network-parallel.test.sh- all passbash tests/fm-secondmate-liveness.test.shon both the change and the base commit - same single failure in both, pre-existingAGENTS.md:46- Judgment call, deliberately not changed: the two summary enumerations of captain-private gitignored paths (AGENTS.md:46 and CONTRIBUTING.md:41, both listing .env, data/, state/, config/, projects/, .no-mistakes/) do not name the new .treehouse/ pool directory. I left them alone rather than synchronizing a third and fourth copy of the same fact: the home-layout tree at AGENTS.md:96 and docs/configuration.md:17 are the authoritative owners and both now describe .treehouse/, and .gitignore already prevents any accidental commit. Raising it here only so the omission reads as intentional placement rather than an oversight.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.