Skip to content

fix: protect Treehouse pool slot ownership - #135

Merged
dnth merged 23 commits into
mainfrom
fm/fm-pool-durable-fixes
Sep 12, 2026
Merged

dnth merged 23 commits into
mainfrom
fm/fm-pool-durable-fixes

Conversation

@dnth

@dnth dnth commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Intent

Ship the durable half of the Treehouse pool-orphan diagnosis (data/fm-aceh-pool-recovery-diagnosis/report.md): pool slots whose backing repositories disappeared or were reassigned must not be mistaken for usable slots, and firstmate teardown must not reset, kill, or return a slot another task owns.

Requirements: (1) bump the treehouse installer pin in bin/fm-install-treehouse.sh from v2.1.1 to a v2.3.x release with updated release hashes, with the installed treehouse failing closed on unverifiable slots instead of attempting resets; (2) adapt upstream firstmate b028e8b - slot-ownership verification before destructive teardown; (3) adapt upstream 7d14fc1 - a durable .fm-slot-owner claim written at spawn under the shared allocation lock; (4) do NOT implement the two open-ended gaps (marker-present-but-unresolvable slots still reported available; meta-absent lease-release path) - they are documented as known-remaining in the commit message per the captain's explicit scope decision.

Deliberate adaptation decisions a reviewer should not flag: upstream files absent from this fork (fm-backlog-transition-lib.sh, fm-remote-job-reap-orphans.sh, fm-home-summary-refresh.sh, submodule test helpers, relaunch-replacement machinery) were dropped rather than imported; fork contracts preserved - inside-home state symlinks remain allowed in collect_descendant_task_locks (only links escaping the home refuse), devin/hermes hook-token cleanup lists kept, validate_spawn_pool_lease and OMP marker clearing kept ahead of the claim write, and the claim is written only on the pane-driven get path because raw-launch and OMP paths already use Treehouse's durable --lease --lease-holder. The shared project lock intentionally makes a concurrent same-project spawn or teardown refuse fast with "another Treehouse slot allocation or return is in progress"; the herdr e2e test now retries the losing spawn once the winner publishes, mirroring upstream's task-set-lock tolerance. A remote secondmate parent binding terminates the local root-home walk (adapted from upstream 64d3905) so a remote-seeded home anchors its own project lock. Post-pipeline follow-up authorized by firstmate decision nm-ci-herdr-rerun: the ready-file publication wait reads FM_TREEHOUSE_READY_POLLS (production default unchanged at 60) and the herdr e2e suite exports 240, a bounded mitigation for a twice-observed CI-only 60s publication stall at the suite's peak-load point where the pane's get waits on treehouse's pool flock.

Acceptance: AC1 installer pin v2.3.x with correct hashes and fail-closed acquire/skip proven on a scratch pool; AC2 teardown refuses/leaves untouched a slot another task record names, with regression tests; AC3 a mismatched owner claim blocks return, with regression tests; AC4 full no-mistakes validation green on the final head. Never merge the PR - the captain holds merge authority.

Firstmate-Validation-Generation: 9266ce7b8687e04dbac7585b6756d3ec

What Changed

  • Pin the Treehouse installer to v2.3.0 with updated hashes, and fail closed when a pool slot cannot be verified or claimed.
  • Add shared allocation/task-set locking and durable .fm-slot-owner claims; teardown now checks cross-home slot ownership and leaves reassigned or unreadable slots untouched while completing only the task’s own cleanup.
  • Expand pool, teardown, secondmate, and Herdr regression coverage and document the locking/ownership contracts; bound Treehouse ready-file polling and improve checkpoint signal-grace handling.

Risk Assessment

⚠️ Medium: The change is materially broad and introduces intertwined locking and ownership-state transitions, but the reviewed paths satisfy the stated intent without a source-verifiable defect.

Testing

Drove installer verification, fail-closed scratch-pool acquisition, contested and malformed ownership teardown guards, durable spawn claims, and orphan recovery end-to-end; all scenarios passed with reviewer-visible logs captured.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Installer pins Treehouse v2.3.x and unverifiable scratch-pool state refuses acquisition ✅ pass live treehouse-installer-v230.log; treehouse-scratch-pool-fail-closed.log
Teardown refuses when another task record names the pool slot ✅ pass live bash tests/fm-teardown-endpoint-safety.test.sh
Mismatched or malformed slot-owner claim blocks destructive return ✅ pass live bash tests/fm-teardown-endpoint-safety.test.sh
Spawn persists a durable slot-owner claim under allocation ✅ pass live bash tests/fm-spawn-pool-base-freshen.test.sh
Interrupted unregistered Treehouse target is recovered without unsafe reuse ✅ pass live bash tests/fm-treehouse-orphan-recovery.test.sh
Evidence: Treehouse installer v2.3.0 verification
fm-install-treehouse.sh: downloading treehouse-v2.3.0-linux-amd64.tar.gz from https://github.com/kunchenguid/treehouse/releases/download/v2.3.0/treehouse-v2.3.0-linux-amd64.tar.gz
fm-install-treehouse.sh: installed treehouse v2.3.0 to /tmp/fm-installer-evidence.qvbFDZ/bin/treehouse
v2.3.0
v2.3.0
Evidence: Scratch pool fail-closed acquisition
acquire_initial=/tmp/fm-pool-evidence.wr7E3j/pool/.treehouse/repo-49b8e3/1/repo
tampered_state_path=/tmp/fm-pool-evidence.wr7E3j/missing/repo
retry_rc=1
🌳 Setting up worktree...
failed to create worktree: git worktree add --detach /tmp/fm-pool-evidence.wr7E3j/pool/.treehouse/repo-49b8e3/1/repo main: fatal: '/tmp/fm-pool-evidence.wr7E3j/pool/.treehouse/repo-49b8e3/1/repo' already exists

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (4) ✅
  • 🚨 bin/fm-teardown.sh:2479 - Forced secondmate teardown now acquires each descendant home's .task-set.lock and assumes this serializes task publication, but fm-spawn.sh never acquires that lock before creating/publishing a task record (it only uses per-task and Treehouse project locks). A spawn can therefore publish a new child after collect_descendant_task_locks enumerates the home; the later descendant preflight/cleanup can process and return that newly published task's slot without lifecycle locking, leaving another worker's slot destructively reclaimed. Add the matching task-set lock acquisition to the spawn publication path with the documented lock ordering.
  • ⚠️ bin/fm-watch-checkpoint.sh:66 - The new timeout handler interprets FM_SIGNAL_GRACE=0 as unset because of my $grace = $ENV{FM_SIGNAL_GRACE} || 5, changing the established zero-grace behavior (used by existing watcher/test invocations) into a 5-second wait before SIGKILL. Preserve an explicitly supplied zero while defaulting only when the variable is absent or invalid.

🔧 Fix applied.
2 issues (1 error, 1 warning) still open:

  • 🚨 bin/fm-watch-checkpoint.sh:73 - When FM_SIGNAL_GRACE=0, the timeout handler executes alarm 0, which cancels the SIGKILL alarm, then blocks forever in waitpid if the child ignores SIGTERM. This regresses the documented zero-grace behavior and can leave checkpoint cleanup hung indefinitely; handle zero as an immediate kill (or otherwise preserve a non-cancelled kill path).
  • ⚠️ 15000000:1 - The diff adds a tracked top-level file named 15000000 containing bad, which is unrelated to the Treehouse pool durability intent and has no required runtime or test role. Remove this unrequired component (the remedy is a scope cleanup requiring authorization under the simplification policy).

🔧 Fix applied.
1 error still open:

  • 🚨 bin/fm-wake-lib.sh:1054 - fm_treehouse_slot_owner_state accepts duplicate claim fields and uses the last task= line (bin/fm-wake-lib.sh:1054-1066). A malformed or tampered marker such as task=stale-task\ntask=current-task is therefore classified as mine, allowing teardown to kill/reset/return a slot actually reassigned to another task. Parse the claim as a strict format (exactly one valid task= and reject duplicate/unknown malformed lines) and leave it unsafe on ambiguity.

🔧 Fix applied.
1 error still open:

  • 🚨 bin/fm-spawn.sh:3932 - If fm_treehouse_slot_owner_claim fails after the pane-driven fm-treehouse-get.sh has acquired a slot (for example, when .fm-slot-owner is an unclaimable directory), fm-spawn.sh exits at this point without marking the acquisition for abort cleanup or returning the lease. The pane remains in the Treehouse shell and the slot can stay leased indefinitely, stranding pool capacity and causing later spawns to fail. Ensure the claim-failure path returns the just-acquired slot (or otherwise arms equivalent cleanup) before refusing the spawn.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Installer pins Treehouse v2.3.x and unverifiable scratch-pool state refuses acquisition ✅ pass live treehouse-installer-v230.log; treehouse-scratch-pool-fail-closed.log
Teardown refuses when another task record names the pool slot ✅ pass live bash tests/fm-teardown-endpoint-safety.test.sh
Mismatched or malformed slot-owner claim blocks destructive return ✅ pass live bash tests/fm-teardown-endpoint-safety.test.sh
Spawn persists a durable slot-owner claim under allocation ✅ pass live bash tests/fm-spawn-pool-base-freshen.test.sh
Interrupted unregistered Treehouse target is recovered without unsafe reuse ✅ pass live bash tests/fm-treehouse-orphan-recovery.test.sh
  • bash tests/fm-teardown-endpoint-safety.test.sh
  • bash tests/fm-spawn-pool-base-freshen.test.sh
  • bash tests/fm-treehouse-orphan-recovery.test.sh
  • bash bin/fm-install-treehouse.sh <temporary destination>
  • Live v2.3.0 scratch-pool acquisition with tampered state path
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

kunchenguid and others added 16 commits September 12, 2026 16:49
* fix(bin): verify pool-slot ownership before returning a worktree slot

Workers were killed when cleanup returned a Treehouse pool slot that a
different, live task had already taken. Teardown now proves the slot is
genuinely this task's before releasing it: it refuses when another task
record claims the same live worktree path, or when the endpoint's working
directory contradicts the recorded slot, and that refusal holds under
--force. Slot allocation, metadata publication, ownership verification,
and slot return are serialized across linked firstmate homes, and forced
secondmate cleanup verifies descendant slot ownership before returning
any child worktree.

Regression coverage drives the scripts with two task records naming one
slot path and asserts the live worker survives and its slot is not reset.

* no-mistakes(review): Protect slots across cloned Firstmate homes

* no-mistakes(test): Gate teardown locking on genuine Treehouse slots

* no-mistakes(test): Clarify pooled descendant slot gating

* no-mistakes(test): Synchronize watcher re-arm test on process exit

* no-mistakes(test): Wait for watcher cleanup before timeout escalation

* no-mistakes(document): Document pool-slot ownership safeguards

* no-mistakes(ci): Fixed all reported CI issues: normalized bare local Git origins to the same Treehouse project-lock identity as absolute clone origins; resolved ShellCheck SC1091 with explicit conditional sourcing; and taught concurrent Herdr teardown coverage to retry expected Treehouse lock contention. Added behavioral regression coverage for bare/absolute origin lock identity. Verified endpoint-safety tests, watcher tests, full CI lint, and the previously failing Herdr teardown assertion

* fix(bin): resolve relative origins from repository root

* no-mistakes(ci): Fixed teardown so an exact recorded endpoint may change cwd without falsely vetoing cleanup. Removed cwd-based ownership refusal while preserving cross-home record exclusivity and project locking. Updated behavioral coverage for both foreign slot ownership refusal and moved-cwd teardown success. Endpoint-safety, backend, watcher, checkpoint, and targeted lint checks pass. Real Herdr presentation E2E progressed successfully but exceeded the 600s local timeout

(cherry picked from commit b028e8b)
… untouched (kunchenguid#4243)

* fix(teardown): refuse to return a Treehouse pool slot reassigned to another task

A pool slot is reused across tasks, so a finished task's worktree= line can name
a slot a different, live task now holds. Teardown already refused when a second
task record named the same live path, but that scan cannot prove the record it
is tearing down is the current owner: the task that took the slot next may leave
no record the scan can reach - its own worker may have exited and its record been
cleaned up, or it may live in a home this machine does not register. Teardown
then killed every process under the path, hard-reset it and returned it, and its
unlanded-work refusal never fired because it was inspecting a directory that no
longer belonged to the task being torn down (observed 2026-09-07).

Treehouse's own state file cannot answer the ownership question. It records a
slot's owner as a live process lease (owner_pid plus owner_started_at, with
`treehouse status` reporting in-use from the processes actually running under
the path), which names no task and is released by the very event that makes a
record stale - the worker exiting. An unleased slot therefore reads identical
whether it is still this task's or has since been handed on, and a slot whose
new holder has also exited but left uncommitted work reads as free. So the
identity source is Firstmate's own claim, not Treehouse's lease.

fm-spawn writes that claim - the task id - into the slot at the moment it takes
it, under the same project lock that allocates the slot, and fm-teardown drops it
only after the slot is genuinely returned. It lives at <pool>/<slot>/.fm-slot-owner,
a sibling of the repo checkout rather than a file inside it, so claiming a slot
can never dirty the copy the landed-work checks inspect. A claim naming another
task, or one that cannot be read, refuses; --force does not lift either refusal,
because --force authorizes discarding this task's unlanded work, never another
task's live work. A slot that cannot be claimed refuses the spawn instead.

An absent claim proceeds on exactly the record-scan protection it had before:
slots taken before claims existed, and slots already returned, carry none, and
refusing those would strand every task in flight across this change on no
evidence at all.

The refusal is deliberately all-or-nothing rather than partially completing the
task's own cleanup. state/<id>.meta is the only durable record naming the
worktree and endpoint, so removing it would destroy the evidence needed to
reconcile which record is wrong, and its removal is one step with the backlog
transition. Nothing is stranded: clearing the stale worktree= line leaves a
record with no slot to release, which then tears down normally, and the refusal
names that remedy.

Repairing the previous claimant's stale worktree= line at spawn time is left for
separate work. It would have the new owner write another task's record - the same
class of cross-task mutation this bug is - and would need that record's own meta
lock; with the claim in place teardown refuses on evidence rather than depending
on the stale pointer having been scrubbed. For the same reason the relaunch path
writes no claim: it holds no allocation lock, and a record whose worktree= is
already stale would stamp the wrong task's claim onto a live sibling's slot.

The regression reproduces the reuse sequence with only one discoverable record,
including a clean, fully landed ship copy torn down without --force - the shape
of the real incident, which the previous code returned to the pool - and fails
against the previous code; the existing two-record, cross-home, own-slot
and no-claim cases still pass unchanged.

This builds ON upstream b028e8b (kunchenguid#3837), which is already in this branch's base
(origin/main 40c50ea) and owns the record-exclusivity scan. Nothing here
replaces that scan; the claim is the positive proof it cannot supply.

Claude-Session: https://claude.ai/code/session_01JTBmuqKugaPUj7k9TXQwFS

* no-mistakes(review): teardown leaves reassigned slot; spawn abort drops claim

* no-mistakes(review): narrow Treehouse lease evidence; gate abort claim release on lock

* no-mistakes(review): pin spawn-side slot claim; narrow abort-release header

* no-mistakes(document): docs: point slot-claim rationale at fm-wake-lib owner

(cherry picked from commit 7d14fc1)
Adapts the lock anchoring to this fork's remote secondmate homes (the
route=remote parent record bin/fm-secondmate-parent-lib.sh owns): a remote
binding ends the local root walk at the home holding it, matching upstream
64d3905, so a remote-seeded home anchors and resolves its own project lock
instead of failing to derive one and refusing every pool-slot teardown.
Bumps bin/fm-install-treehouse.sh from v2.1.1 to v2.3.0 with the release's
published SHA-256 for all four archives (linux/darwin x amd64/arm64). v2.3.0's
acquire fails closed on slots it cannot verify - skipping them and
provisioning a fresh slot instead of attempting a reset - and its status
reports a marker-absent slot as damaged rather than available, which removes
the reset attempt that produced the misleading ancestry refusal in the aceh
pool-orphan incident.

Verified locally: a fresh install of the pinned archive reports v2.3.0; on a
scratch pool containing a slot with a dangling .git marker and a slot with no
marker at all, status reports the marker-absent slot damaged and get --lease
skips both unverifiable slots without touching either, provisioning a new
slot instead.

Known-remaining gaps, documented rather than fixed per the captain's decision
(scope: data/fm-aceh-pool-recovery-diagnosis/decision-pool-durable.md):
a slot whose .git marker exists but resolves nowhere is still reported
available (StatusDamaged covers only marker-absent slots), and the
meta-absent lease-release path is still unimplemented.
…home

Upstream b028e8b's collect_descendant_task_locks refuses any symbolic-link
state path. This fork's secondmate contract (pinned by
fm-secondmate-safety.test.sh) permits links that resolve inside the home -
the target is removed with the home - while refusing links that escape it.
Resolve the link target and compare it against the resolved home boundary:
links escaping the home, or that cannot be resolved at all, still refuse;
inside-home links proceed and the later blanket -L refusal is dropped.
7d14fc1 declares RELAUNCH_REPLACEMENT_* state for upstream's
relaunch-replacement machinery, which this fork lacks. Shellcheck flagged
the dead globals; removing them keeps the pick limited to the claim
behavior being adapted.
The shared project lock now makes a concurrent same-project spawn refuse
fast with "another Treehouse slot allocation or return is in progress"
instead of racing allocation. Mirror upstream's task-set-lock tolerance:
capture each spawn's status, accept that one refusal message, and retry the
task once the winner publishes. Applied to the order pair, the post-create
abort fixtures (which still must fail on their armed validation error), and
the three focus waves. The cross-home wave keeps bare waits - those homes
are not parent-linked, so each anchors its own lock and cannot contend.
…ed-secondmate child preflight (which consumed Orca mock responses twice) and allowed explicit STATE overrides to anchor the shared Treehouse project lock when the synthetic FM_HOME lacks state/. Verified fm-backend, fm-backend-orca, and fm-teardown-endpoint-safety tests pass. The Herdr presentation failure was an external timing/fixture timeout, not reproduced as a deterministic code defect
…POLLS

The real-Herdr presentation suite failed twice in CI at the multi-home
section's first spawn: treehouse get did not publish its acquired worktree
within the fixed 60s. Diagnosis found no defect in the lock or pane path:
the pane-side get is ~1s of work (~4s per spawn on CI), the pane never
touches the project lock the waiting spawn holds, and treehouse's pool flock
is the only unbounded wait in the path - a transient holder or a loaded
runner stalls the pane's publication without bound. The ready-file wait is
now read from FM_TREEHOUSE_READY_POLLS (default unchanged at 60s); the
suite exports 240, ~18x over the worst observed CI get-bearing step, so a
transient stall at the suite's peak-load point stays inside the budget.
@dnth dnth changed the title fix: protect reassigned Treehouse pool slots fix: protect Treehouse pool slot ownership Sep 12, 2026
…n from either the Treehouse project lock or the task-publication lock, retrying after the winner completes. The full real-Herdr presentation E2E suite passes
…E2E multi-home spawn failure paths: parses inspect targets, captures pane output/process info/cwd, checks ready-file artifacts, and snapshots treehouse processes without changing test outcomes. `bash -n`, `git diff --check`, and the full presentation E2E suite pass locally
… with a scoped suppression preserving the required full `ps` snapshot. Targeted lint, syntax, and diff checks pass; the Herdr E2E suite passes locally. The CI Herdr failure appears environment-specific (Herdr 0.7.4 pane disappeared while an orphaned treehouse process remained), not reproduced locally
…he target pane disappears during ready-file polling, with an accurate pane-death error. Added a single retry for pane-disappeared multi-home spawns while retaining final diagnostics. `bin/fm-lint.sh`, shell syntax/diff checks, and the full Herdr presentation E2E suite pass locally
…eserve workspace-qualified pane IDs and capture qualified pane read, pane status fields, and process-info. Bash syntax, ShellCheck, diff checks, and the full Herdr presentation E2E test pass locally
…ulti-home E2E suite. It records workspace/pane lists, qualified pane status (agent_status, foreground_cwd, cwd), and recent pane output every ~15s, includes the snapshot tail in spawn failure diagnostics, and stops cleanly after the section. Bash syntax, ShellCheck, and diff checks pass; a local run began successfully but exceeded the 45s bounded smoke window
…`read-tree --reset` with ancestry validation and SAFE_FILE publication, propagated pane-side acquisition failures via `.failed` ready markers, and surfaced them promptly from `fm-spawn.sh`. `bash -n`, targeted ShellCheck, diff checks, and `fm-treehouse-orphan-recovery` plus `fm-spawn-pool-base-freshen` tests pass
@dnth
dnth merged commit 6ab9e43 into main Sep 12, 2026
15 checks passed
@dnth
dnth deleted the fm/fm-pool-durable-fixes branch September 12, 2026 23:46
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.

3 participants