Skip to content

fix(spawn): claim a task's pool worktree so it is never reassigned under a live task - #4

Closed
talktejas wants to merge 5 commits into
mainfrom
fm/fm-worktree-pool-reassigns-live-task
Closed

talktejas wants to merge 5 commits into
mainfrom
fm/fm-worktree-pool-reassigns-live-task

Conversation

@talktejas

Copy link
Copy Markdown
Owner

The fault

On 2026-08-19 a spawn was handed /home/tds/.treehouse/jt2627s-445362/3/jt2627s, which another task's own record still named as its live working copy. The pool reset that directory to a detached HEAD, evicting a paused task from the copy holding its work. Nothing was lost only because that task had already pushed its branch; had the copy held anything uncommitted, the spawn would have destroyed it silently.

The cause is what the pool can see. Treehouse counts a slot as in use only while a process is running inside it, so a task whose agent has exited - paused, crashed, or simply between turns - leaves its own working copy looking free, and the next treehouse get takes it.

The fix

Of the two options in the report - refuse a claimed copy, or stop the pool offering it - this takes the second, because it is the only one that acts before the copy has already been reset.

bin/fm-spawn.sh now takes the pool's own durable claim itself (treehouse get --lease) instead of relying on the pane's treehouse get subshell to hold the slot, then moves the pane into the leased copy. Treehouse never hands a leased slot to a later get and never prunes it, with or without a process inside.

How the claim is released, since a claim nothing can release is the trap this fix could otherwise create:

  • Normally bin/fm-teardown.sh returns the worktree, and treehouse return releases the lease with it.
  • An aborted spawn returns it in cleanup, holder-matched, before any record can name it.
  • Otherwise the holder label names the exact record that owns it (fm:<task-id>@<state-dir>), and the task record carries the same value as worktree_lease=, so a lease outliving its task is self-identifying and releasable with one command. A stale lease costs one pool slot until then; it can never wedge a spawn, because treehouse allocates another slot rather than refusing.

The named refusal the report asked for is kept as a second line of defence, for the one copy a lease cannot protect: a task spawned before leases existed, holding its slot with nothing but its processes. If the pool offers a copy that another record in this home still names and that record carries no claim of its own, the spawn refuses and names the task that holds it. The claim is deliberately kept on that refusal - releasing it would offer the same copy to the next spawn, which would refuse again and deadlock the pool on it; holding it quarantines that one slot, the next spawn is given a different copy, and the refusal prints the command that releases it.

Two consequences worth calling out:

  • The leased path is screened for isolation before the pane is touched at all, so a non-isolated copy is refused earlier, and the refusal now carries the reason it was rejected.
  • The pane enters the copy in a subshell rather than with a top-level cd. A top-level cd puts the pane's own shell inside the worktree, where teardown's leaked-process reaper ends it as a stray process instead of letting the backend close its pane - on Herdr that skipped the projection's confirmed close and left its presentation journal quarantined. The subshell keeps the process shape the pool's interactive get produced. This was caught by the existing Herdr presentation E2E, not by inspection.

Delivery

This ships direct-PR rather than through the full review pipeline, deliberately: the pipeline's cached copy of this repository points at the project firstmate was forked from rather than at ours, so it rebases any change onto months of unrelated history and fails. Fixing that needs the captain's permission to update a tool, which has not been given yet. Rather than wait, this goes direct-PR with the reason stated.

Verified locally

New: tests/fm-spawn-worktree-lease.test.sh - the claim is taken and labelled by the owning record, an aborted spawn returns it holder-matched, a copy another record claims is refused by name and stays claimed, a record holding its own claim does not block a spawn, and - against the real pool, skipped when treehouse is absent - a leased slot with no process inside it is not handed to a later get. Dated evidence for that last guarantee is recorded in docs/verification/runtime-backends.md.

39 of the 58 suites that exercise fm-spawn were run locally; 38 reported, 36 passed, 2 failed. The remaining 19 were stopped rather than queued, and CI covers them. Neither failure is from this change:

  • tests/fm-secondmate-liveness.test.sh - "Herdr pane state unknown should map to unreadable, got 'missing'". Fails identically on the untouched base commit 11217bc (exported to a clean tree and run there); bin/backends/herdr.sh is not touched by this branch.
  • tests/fm-secondmate-harness.test.sh - "first config push did not reach pointer delivery", a 2-second polling window in a fm-config-push.sh concurrency case. It passed on its own run earlier on this same branch and failed only under a loaded parallel sweep; bin/fm-config-push.sh is not touched.

Two others seen during the sweep were also confirmed against the untouched base: tests/fm-backend-tmux-smoke.test.sh ("the tmux task shell did not become ready", raw tmux, before any firstmate code runs) and tests/fm-remote-secondmate-lifecycle-e2e.test.sh ("remote spawn never reached its blocked inheritance write"). Both fail the same way on 11217bc here.

bin/fm-lint.sh and bin/fm-doc-audience-check.sh are clean.

Treehouse counts a pool slot as in use only while a process runs inside
it, so a task whose agent has exited - paused, crashed, or simply between
turns - leaves its own working copy looking free. The next spawn was then
handed that copy and reset it to a detached HEAD, silently, with no
refusal (observed 2026-08-19; nothing was lost only because that task had
already pushed its branch).

fm-spawn now takes the pool's own durable claim itself - `treehouse get
--lease` - instead of relying on the pane's `treehouse get` subshell to
hold the slot, and moves the pane into the leased copy. Treehouse never
hands a leased slot to a later get and never prunes it, with or without a
process inside, so the claim is enforced by the layer that does the
allocating rather than re-derived from task records after the copy has
already been reset.

The lease holder label names the task record that owns it
("fm:<id>@<state-dir>"), which is what keeps a claim from outliving its
task unnoticed: teardown's `treehouse return` releases it in the ordinary
case, an aborted spawn returns it holder-matched before any record can
name it, and anything left over is self-identifying and releasable with
one command. A stale lease costs a pool slot until then; it can never
wedge a spawn, because treehouse allocates another slot rather than
refusing.

The leased path is now screened for isolation before the pane is touched
at all, so a non-isolated copy is refused earlier and the refusal carries
the reason it was rejected.
The pool cannot see a claim on a copy whose only claim was the processes
inside it - the shape every task spawned before leases existed leaves
behind - so fm-spawn checks this home's own records too and refuses by
name rather than launching into another worker's copy.

The claim is deliberately kept on that refusal. Releasing it would offer
the same copy to the next spawn, which would refuse again and deadlock
the pool on it; holding it quarantines that one slot instead, and the
refusal prints the exact command that releases it once the contradictory
records are reconciled.
The spawn now runs treehouse itself instead of typing `treehouse get`
into the pane, so every fixture that drives a crew or scout spawn has to
answer the acquire. tests/lib.sh owns that stub; each suite's own no-op
treehouse is replaced by it.
worktree_lease= in the task record says which claim releases that copy,
and it is what tells the claim-conflict refusal apart from its target: a
record carrying its own claim cannot have had its copy offered by the
pool, so only an unclaimed record - the shape a task spawned before
leases existed leaves - blocks a spawn. A relaunch carries the original
claim forward.
…t did

A plain top-level cd would leave the task pane's own shell inside the
worktree, where teardown's leaked-process reaper ends it as a stray
process instead of letting the backend close its pane. On Herdr that
skipped the projection's confirmed close and left its presentation
journal quarantined. Entering in a subshell keeps the process shape the
pool's interactive get produced: the pane's shell stays in the project,
only the foreground shell sits in the copy.

The presentation fixture arms its post-create abort through a refused
claim (still after the pane exists) and reads the abort interleaving from
the ordered CLI call log, which records both close mechanisms.
@talktejas
talktejas force-pushed the fm/fm-worktree-pool-reassigns-live-task branch from b0b184d to 72c6165 Compare September 20, 2026 06:18
talktejas added a commit that referenced this pull request Sep 20, 2026
…der a live task

Supersedes #4, which cannot be revalidated: its branch was rewritten (the
pipeline rebased it onto a moved main, and custody was then returned at that
rebased head), and no-mistakes refuses to reconcile its private mirror record
afterwards. Same content, clean branch from main.

On 2026-08-19 a spawn was handed a pooled working copy that another task's own
record still named as its live copy. The pool reset it to a detached HEAD,
evicting a paused task from the copy holding its work; nothing was lost only
because that task had already pushed. Treehouse counts a slot as in use only
while a process runs inside it, so a task whose agent has exited - paused,
crashed, or between turns - leaves its copy looking free.

bin/fm-spawn.sh now takes the pool's own durable claim itself
(treehouse get --lease) instead of relying on the pane's subshell to hold the
slot, then moves the pane into the leased copy in a subshell so the pane's own
shell does not sit inside the worktree. Release paths: ordinary teardown returns
the worktree and the lease with it; an aborted spawn returns it holder-matched
in cleanup; otherwise the holder label names the owning record and the task
records worktree_lease= so a stale claim is self-identifying and releasable with
one command.

A named refusal stays as a second line of defence for the one copy a claim
cannot protect - a task spawned before claims existed, holding its slot with
nothing but its processes. The claim is deliberately kept on that refusal, so
the slot is quarantined rather than offered to the next spawn in a refuse-loop.

Upstream's slot-owner claim marker is kept as-is and this claim sits above it:
the lease stops the pool reassigning a live copy, the marker answers at teardown
whether the slot beside a record is still that record's own.
talktejas added a commit that referenced this pull request Sep 20, 2026
* fix(spawn): claim a task's pool worktree so it is never reassigned under a live task

Supersedes #4, which cannot be revalidated: its branch was rewritten (the
pipeline rebased it onto a moved main, and custody was then returned at that
rebased head), and no-mistakes refuses to reconcile its private mirror record
afterwards. Same content, clean branch from main.

On 2026-08-19 a spawn was handed a pooled working copy that another task's own
record still named as its live copy. The pool reset it to a detached HEAD,
evicting a paused task from the copy holding its work; nothing was lost only
because that task had already pushed. Treehouse counts a slot as in use only
while a process runs inside it, so a task whose agent has exited - paused,
crashed, or between turns - leaves its copy looking free.

bin/fm-spawn.sh now takes the pool's own durable claim itself
(treehouse get --lease) instead of relying on the pane's subshell to hold the
slot, then moves the pane into the leased copy in a subshell so the pane's own
shell does not sit inside the worktree. Release paths: ordinary teardown returns
the worktree and the lease with it; an aborted spawn returns it holder-matched
in cleanup; otherwise the holder label names the owning record and the task
records worktree_lease= so a stale claim is self-identifying and releasable with
one command.

A named refusal stays as a second line of defence for the one copy a claim
cannot protect - a task spawned before claims existed, holding its slot with
nothing but its processes. The claim is deliberately kept on that refusal, so
the slot is quarantined rather than offered to the next spawn in a refuse-loop.

Upstream's slot-owner claim marker is kept as-is and this claim sits above it:
the lease stops the pool reassigning a live copy, the marker answers at teardown
whether the slot beside a record is still that record's own.

* no-mistakes(review): guard abort lease release on record; refresh claim rationale

* no-mistakes(review): gate and verify holder-matched lease release

* no-mistakes(review): bound pool allocation and name orphaned claims

* no-mistakes(review): assert lockdir release; scope recovery commands to project

* no-mistakes(document): document pool-allocation bound and widened treehouse lease gate

* no-mistakes(review): install flag-modelling treehouse lease stub in stale suites

* no-mistakes(review): pin CI treehouse to v2.1.0 for holder-matched release

* no-mistakes(test): teach dispatch-profile timeout stub the GNU -k shape

* no-mistakes(document): document worktree_lease meta field and its release command
@talktejas

Copy link
Copy Markdown
Owner Author

Superseded by #10, which is merged. That branch could not be reconciled after its run was aborted, so the work was redone on a fresh branch.

@talktejas talktejas closed this Sep 20, 2026
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