Skip to content

fix(spawn): record task worktrees from allocator leases - #5

Merged
elixlabssolutions merged 3 commits into
mainfrom
fm/spawn-worktree-metadata-race
Aug 6, 2026
Merged

elixlabssolutions merged 3 commits into
mainfrom
fm/spawn-worktree-metadata-race

Conversation

@elixlabssolutions

@elixlabssolutions elixlabssolutions commented Aug 6, 2026 •

Copy link
Copy Markdown

Intent

Root-cause and fix the long-standing defect where bin/fm-spawn.sh records a worktree= value that is not where the agent actually is (7 live occurrences across two repos, claude and codex harnesses, ship and scout tasks). Root cause, proven by source reading of treehouse v2.0.0 and herdr 0.8.0 plus live pool forensics and a sandbox reproduction: the spawn inferred the worktree by typing 'treehouse get' into the pane and polling the backend's current-path probe for the first path that left the project; treehouse's acquire walks candidate pool slots lowest-first running git probes with cwd inside not-yet-chosen slots, and herdr's foreground_cwd reports the first foreground-job member whose cwd differs from the shell's while the leader (treehouse) still sits in the project - so the poll records a slot treehouse never allocated, typically the pool's lowest available slot (two live slots are permanently wedged by stale index.lock files, so they are probed first and skipped on every acquire). validate_spawn_worktree only checked generic properties of the recorded value, so it could never catch the divergence; hooks and the vault guard were installed into the wrong worktree and teardown's unlanded-work refusal inspected the wrong path. Deliberate decisions in this change: (1) take the worktree from the allocator - 'treehouse get --lease --lease-holder fm-' prints the allocated path as the sole stdout line and holds a durable lease until 'treehouse return' (mirrors fm-home-seed.sh's existing secondmate-home pattern; bootstrap already blocks dispatch on a treehouse without lease support; the durable lease also closes a reuse hole where a crashed task's slot was reclaimable while unlanded work sat inside it); (2) move the pane with a plain quoted cd instead of the treehouse subshell - every backend's probe follows a top-level shell cd, which zellij's and cmux's probes could never do for the subshell; (3) demote the probe to an independent confirmation witness: launch proceeds only once the pane is observed at the leased path, one equal read being arrival, so the record and an independent observation must agree and a pane that never arrives fails the spawn loudly while the pre-launch abort path releases the lease (teardown's existing 'treehouse return --force' releases it at end of life); (4) the old two-consecutive-agreeing-reads settle heuristic is deliberately removed - equality with the known allocation supersedes it and cannot be satisfied by a stale foreign path; (5) new env knobs FM_SPAWN_WORKTREE_CONFIRM_POLLS/INTERVAL exist only so tests can bound the confirmation wait, defaulting to the old 60x1s budget; (6) tests/lib.sh gains a shared lease-capable fake treehouse whose lease path deliberately defaults to FM_FAKE_PANE_PATH so existing spawn tests need only a one-line construction swap, and fm-backend.test.sh gets a bespoke baked-path fake matching its bespoke tmux stubs; (7) tests/fm-spawn-worktree-settle.test.sh's new cases were verified red against the old code (meta recorded the probe's stale path) and green after; (8) tests/fm-tangle-guard.test.sh's window-construction assertions were updated from 'treehouse get typed to the stable window id' to 'the worktree cd and confirmation poll target the stable window id' - same kunchenguid#134 invariant, new flow; (9) backend adapter and doc comments were reworded from worktree-discovery to worktree-confirmation language, keeping each adapter's verified probe facts, and docs/verification/runtime-backends.md annotates that the nested-subshell discovery leg of the old zellij/cmux smoke evidence is superseded by the lease+cd flow rather than pretending those dated smokes were re-run; (10) the Orca branch is untouched because it already takes its worktree from Orca's own create call, and secondmate spawns never entered this block. Explicitly out of scope per the captain: no sweep of historical stale worktree= metadata (recommended separately), and the sibling defects spawn-stale-turnend-hook and turnend-guard-race-s1 are not folded in.

What Changed

  • Lease each non-Orca ship or scout worktree directly from treehouse, record that allocated path, move the task pane there, and require backend confirmation before launch; pre-launch failures release the lease.
  • Update runtime backend adapters and documentation to treat path probes as confirmation only, with shared fakes and regression coverage for stale probe paths and failed confirmation.

Risk Assessment

✅ Low: Captain: The change is tightly bounded and correctly makes the allocator authoritative while requiring backend confirmation before launch.

Testing

Dedicated regression, direct CLI evidence, stable-window targeting, dispatch, trace, busy-adapter, and worktree hook coverage passed. This is a shell lifecycle change with no UI surface; the CLI transcript is the appropriate end-user evidence. Two unrelated constraints remain: a historical send-parity fixture failure before spawn coverage and missing Python tomllib for Kimi tests.

Evidence: Lease-to-metadata end-to-end transcript

Allocator lease, pane confirmation, persisted metadata, and failed-confirmation lease release are shown in one end-to-end CLI transcript.

SCENARIO: two stale pane readings from a distinct real checkout, then arrival in the allocator lease path
spawn_exit=0
allocator_lease_path=/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/success/allocated-worktree
stale_probe_path=/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/success/stale-foreign-checkout
spawn_output:
warning: /var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/success/home/data/manual-lease-proof-z5/brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
spawned manual-lease-proof-z5 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-manual-lease-proof-z5 worktree=/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/success/allocated-worktree
persisted_metadata:
worktree=/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/success/allocated-worktree
project=/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/fm-spawn-worktree-e2e.r7a16Y/success/project
harness=codex
kind=ship
treehouse_calls:
get --lease --lease-holder fm-manual-lease-proof-z5

SCENARIO: pane remains at the stale foreign checkout, so launch must abort and release the lease
spawn_exit=1
allocator_lease_path=/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/failure/allocated-worktree
persistent_stale_probe_path=/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/failure/stale-foreign-checkout
spawn_output:
warning: /var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/failure/home/data/manual-release-proof-z6/brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
error: pane was never observed inside the leased worktree /var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/failure/allocated-worktree; refusing to launch. Inspect window firstmate:fm-manual-release-proof-z6
treehouse_calls:
get --lease --lease-holder fm-manual-release-proof-z6
return --force /var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T//fm-spawn-worktree-e2e.r7a16Y/failure/allocated-worktree
- Outcome: ⚠️ 2 warnings across 1 run (9m7s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

⚠️ **Test** - 2 warnings
  • ⚠️ tests/fm-backend.test.sh:704 - Historical fm-send parity fixture fails before its spawn section: unchanged base fm-send exits 1 while the current copy exits 0 under its synthetic old-adapter setup. This is deterministic but outside this commit's changed spawn path.
  • ⚠️ bin/fm-kimi-turnend-hook.sh:36 - Kimi harness coverage cannot run here because the available Python is 3.9.6 and lacks tomllib. No compatible interpreter is installed, and installation is outside the workspace boundary.
  • bin/fm-test-run.sh tests/fm-spawn-worktree-settle.test.sh tests/fm-backend.test.sh tests/fm-tangle-guard.test.sh
  • bin/fm-test-run.sh tests/fm-backend.test.sh
  • /var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KZC3K7BCHJSH17XZ7Q6BETKZ/fm-spawn-worktree-lease-e2e.sh
  • bin/fm-test-run.sh tests/fm-busy-adapter-wiring.test.sh tests/fm-decision-hold-lifecycle.test.sh tests/fm-gate-refuse.test.sh tests/fm-grok-harness.test.sh tests/fm-kimi-harness.test.sh tests/fm-public-followup.test.sh tests/fm-shared-captain-inheritance.test.sh tests/fm-spawn-dispatch-profile.test.sh tests/fm-trace-context-spawn.test.sh tests/fm-vault-guard.test.sh
  • bin/fm-test-run.sh tests/fm-decision-hold-lifecycle.test.sh
  • bin/fm-test-run.sh tests/fm-vault-guard.test.sh
  • python3 -c 'import tomllib'
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Findings beyond this change (from the investigation)

  1. Historical metadata and pool state need a small repair pass (recommended, deliberately not performed here per scope). All four currently live tasks' worktree= records match pool truth (two were hand-corrected during the incident). But: firstmate pool slot 6 is in-use by codex processes that no live task record claims - consistent with past misrecord damage where teardown returned the wrong slot and stranded the real one; firstmate pool slots 1 and 3 are permanently wedged by stale index.lock files (Jul 16 / Jul 20) in their private git dirs, so every acquire probes and skips them - clearing the locks unwedges them; xlabs-os pool slot 1 is dirty and may hold unlanded work - inspect before any cleanup.
  2. Evidence for spawn-stale-turnend-hook: hook files survive slot reuse because fm-spawn lists them in .git/info/exclude, which also shields them from treehouse's git clean -fd at return time. A claude-task-then-codex-task reuse therefore leaves the prior claude Stop hook firing for a retired task id - matching that item's symptom exactly. This fix ensures hooks land in the correct worktree, but does not remove them at end of life.
  3. Upstream treehouse note: a stale index.lock silently and permanently wedges a pool slot - status reports it "available", every get probes and skips it, and nothing ever surfaces the wedge. Worth an upstream issue (repro: touch index.lock in a slot's git dir).
  4. Forge CI did not run on this PR: GitHub queued no workflow runs for this head SHA despite Actions being enabled, the PR being clean against main, and PR merge: bring the fork up to date with upstream (150 commits), preserving both fork commits #4 having triggered the same workflows 13h earlier; no branch protection requires them. Compensating local evidence on the exact PR head: bin/fm-lint.sh (the pinned-ShellCheck CI lint) passes, bin/fm-test-run.sh --check-coverage (CI's coverage guard) passes, and the no-mistakes test step ran the spawn-path regression suites green.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

… a pane probe

fm-spawn.sh inferred the worktree by typing `treehouse get` into the fresh
pane and polling the backend's current-path probe for the first path that
left the project. That inference races treehouse's own allocation:
`treehouse get` walks candidate pool slots in order, running git probes
with their working directory inside slots it has not chosen, and a backend
probe can surface those child locations - herdr's foreground_cwd reports
the first foreground-job member whose cwd differs from the shell's while
the treehouse process itself still sits in the project directory. A sample
taken during that window records a slot treehouse never allocated: a real,
distinct worktree that also passes validate_spawn_worktree, which only
checked generic properties of the very value the poll had just chosen.
The wrong record then receives the harness hooks and vault guard, and
teardown either aborts (recorded path not pool-managed) or, worse, runs
its unlanded-work refusal against a clean wrong worktree and passes
vacuously while the agent's real commits sit elsewhere.

Observed live seven times across two repos, both claude and codex
harnesses, and ship and scout tasks. In the two 2026-08-06 occurrences
both spawns recorded the pool's lowest available slot - permanently wedged
by a stale index.lock, so treehouse probes it first and silently skips it
on every acquire - while the agents landed in later slots, and the wedged
slot physically held the wrong task's hook files. Two prior fixes
(physical-path canonicalization, stable window id plus two consecutive
agreeing reads) hardened the inference without removing it; a stable
foreign value defeats any settle heuristic.

Take the worktree from the allocator instead:

- `treehouse get --lease --lease-holder fm-<id>` reserves the slot and
  prints its path as the sole stdout line, mirroring the pattern
  fm-home-seed.sh already uses for secondmate homes; bootstrap already
  blocks dispatch on a treehouse without lease support. The durable lease
  also closes a reuse hole: the old subshell-scoped reservation died with
  its process, leaving a crashed task's slot reclaimable by a later get
  while unlanded work sat inside it.
- The pane is moved with a plain quoted cd; every backend's probe follows
  a top-level shell cd (zellij's and cmux's probes never saw the old
  subshell at all).
- The probe is now a confirmation witness, not the source of the record:
  launch proceeds only once the backend observes the pane at the leased
  path, one equal read being arrival, so the record and an independent
  observation must agree; a pane that never arrives fails the spawn
  loudly and the pre-launch abort path releases the lease. Teardown's
  existing `treehouse return --force` releases the lease at end of life.

The Orca branch already takes its worktree from Orca's own create call
and does not share the defect; secondmate spawns never entered this
block and are unchanged; the flow is harness-independent.

tests/fm-spawn-worktree-settle.test.sh now drives a probe that reports a
stale foreign path for enough consecutive reads to defeat any settle
heuristic - red before this change (meta recorded the stale path), green
now (meta records the leased path) - and proves a persistently wrong
probe fails the spawn and releases the lease. tests/lib.sh gains a shared
lease-capable fake treehouse, defaulting its lease path to the settled
path spawn tests already export.
@elixlabssolutions
elixlabssolutions merged commit 569dae6 into main Aug 6, 2026
1 check passed
elixlabssolutions added a commit that referenced this pull request Aug 7, 2026
…fake treehouse (#7)

The crew-unaffected block spawns an ordinary ship task through
fm-spawn.sh, which since the allocator-lease change (#5) takes the task
worktree from 'treehouse get --lease' before confirming it against the
pane probe. That block's fakebin carried no treehouse at all and the
test's BASE_PATH is the bare system directories, so the spawn died on a
missing allocator before writing its meta record and the crew-unaffected
assertion failed deterministically, in CI and locally. The lease change
migrated every other spawn-driving test to tests/lib.sh's shared fake
treehouse but missed this file.

Install the shared fake in make_launch_capturing_tmux, whose stub set
exists exactly to serve the crew/scout spawn path. The fake leases
FM_FAKE_PANE_PATH, so the recorded worktree and the pane witness agree
and the confirmation step is exercised rather than bypassed. Secondmate
spawns never consult the allocator and are unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant