Skip to content

fix(bin): durably lease ship and scout worktrees - #8

Closed
sparkus wants to merge 9 commits into
mainfrom
fm/fix-lease-ship-worktrees
Closed

fix(bin): durably lease ship and scout worktrees#8
sparkus wants to merge 9 commits into
mainfrom
fm/fix-lease-ship-worktrees

Conversation

@sparkus

@sparkus sparkus commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Intent

Make ship and scout worktrees DURABLY LEASED at spawn so the worktree pool can never hand a still-owned slot to a new spawn and destroy unlanded work (kunchenguid#1441; real data loss observed 2026-08-03 when six concurrent ships reused parked AMPos pool slots and destroyed commit 89b59a2f).

Requirements implemented:

  1. bin/fm-spawn.sh acquires ship/scout worktrees with treehouse get --lease --lease-holder , mirroring fm-home-seed.sh secondmate home leasing, then cds the pane into that path. Preserve isolation assertions so a leased-but-wrong worktree still fails loudly.
  2. bin/fm-teardown.sh releases that lease only when it successfully returns the worktree (existing treehouse return path); refused teardown must never release because that is the state the lease protects.
  3. Recovery/relaunch into a recorded still-present worktree reuses that path and keeps the existing lease rather than acquiring a second one.
  4. Backward compatibility: pre-change unleased worktrees do not break spawn or teardown; chosen behaviour is leave them unleased (do not adopt lease on next touch) and document that in the PR body - spawn reuses a recorded path if present, teardown's treehouse return still frees the slot when cleanup is allowed.
  5. Abort cleanup may return only a lease this spawn freshly acquired, never a recovered/reused worktree; after successful meta publication the abort flag is cleared so teardown owns release.

Acceptance tests (executable): leased slot not handed out by subsequent treehouse get; successful teardown releases lease; refused teardown leaves lease held (refusal exercised); recovery does not double-lease; pre-existing unleased worktrees still spawn/teardown.

Scope stays inside worktree lease lifecycle at spawn, teardown, and recovery - do not redesign the pool, backend layer, or teardown unlanded-work checks. This is firstmate shared tracked material.

What Changed

  • Lease ship and scout Treehouse worktrees under their task IDs, move backend panes into the acquired paths, and atomically publish lease metadata while preserving isolation checks.
  • Serialize spawn, recovery, abort, teardown, and nested-home retirement so recovery reuses only task-held leases, refused teardown preserves them, and successful guarded returns release them.
  • Keep pre-change unleased worktrees unleased while continuing to reuse and return them, raise the Treehouse minimum and CI pin to v2.1.0, and document and test both lifecycle paths.

Risk Assessment

✅ Low: Captain, the registration now remains held through atomic metadata publication or abort, closing the prior retirement race while preserving independent concurrent spawns.

Testing

No baseline test results were supplied. The focused automated suite and end-to-end CLI/state checks demonstrated durable ship and scout leases, pool exclusion, refusal protection, recovery reuse, correct abort ownership, successful release, and legacy compatibility. This is CLI-only behavior, so screenshots were not applicable; transcripts capture the actual operator surface. Broad regression, lint, PR, and CI phases were not run because the outer executor owns them.

Evidence: Targeted lease lifecycle test
ok - six distinct tasks spawn concurrently under one home
ok - retirement cannot begin before spawn metadata publication
ok - spawned ship slot is leased and not handed out by a subsequent treehouse get
ok - successful teardown releases the lease and frees the slot
ok - refused teardown leaves the lease held (refusal exercised)
ok - recovery into an existing worktree does not create a second lease
ok - recovery refuses a returned worktree retained in stale metadata
ok - recovery rejects one-sided treehouse lease metadata
ok - failed metadata publication is atomic and returns its fresh lease
ok - teardown and recovery serialize on the task lifecycle lock
ok - pre-existing unleased worktrees do not break spawn or teardown
# all fm-spawn-worktree-lease tests passed
Evidence: Ship, recovery, refusal, release, and legacy CLI transcript
LEASED SPAWN AND POOL EXCLUSION
spawn: leased worktree for ship-evidence-1
warn: no registry at /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/home/data/projects.md; defaulting project to no-mistakes off
spawned ship-evidence-1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-ship-evidence-1 worktree=/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt
published metadata:
worktree=/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt
treehouse_lease_holder=ship-evidence-1
treehouse_lease_state=held
pool after spawn: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt","status":"leased","lease_id":"test","lease_holder":"ship-evidence-1","leased_at":null,"processes":[]}]
subsequent plain treehouse get exit=1 output=error: no available worktree (all leased)

RECOVERY REUSES THE EXISTING LEASE
recovery: warn: no registry at /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/home/data/projects.md; defaulting project to no-mistakes off
spawned ship-evidence-1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-ship-evidence-1 worktree=/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt
treehouse calls during recovery:
treehouse status --json
lease files after recovery: 1


RECOVERY ABORT PRESERVES THE REUSED LEASE
failed recovery publication exit=1 output=warn: no registry at /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/home/data/projects.md; defaulting project to no-mistakes off
error: could not publish metadata for task ship-evidence-1
treehouse calls during failed recovery publication:
treehouse status --json
pool after failed recovery publication: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt","status":"leased","lease_id":"test","lease_holder":"ship-evidence-1","leased_at":null,"processes":[]}]

REFUSED TEARDOWN KEEPS THE LEASE
teardown exit=1
REFUSED: worktree /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt has work not on any remote and not landed.
unpushed commits:
295cc82 unlanded evidence
2521918 initial
Push the branch, land its PR, or get the captain's explicit OK to discard, then --force.
treehouse calls during refused teardown: pool after refused teardown: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt","status":"leased","lease_id":"test","lease_holder":"ship-evidence-1","leased_at":null,"processes":[]}]

SUCCESSFUL TEARDOWN RELEASES THE LEASE
teardown: /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T/fm-spawn-worktree-lease.B8cIbV/evidence-primary/project: skipped: origin/main does not exist
teardown ship-evidence-1 complete (window firstmate:fm-ship-evidence-1, worktree /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt)
Backlog: ship-evidence-1 just finished. Update data/backlog.md - move ship-evidence-1 to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due.
treehouse calls during successful teardown:
treehouse return --force --if-lease-holder ship-evidence-1 /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt
pool after successful teardown: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-primary/wt","status":"available","lease_id":"test","lease_holder":"","leased_at":null,"processes":[]}]

LEGACY UNLEASED RECOVERY REMAINS UNLEASED AND TEARS DOWN
legacy recovery: warn: no registry at /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-legacy/home/data/projects.md; defaulting project to no-mistakes off
spawned ship-evidence-legacy harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-ship-evidence-legacy worktree=/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-legacy/wt
treehouse calls during legacy recovery: pool while legacy task is running: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-legacy/wt","status":"available","lease_id":"test","lease_holder":"","leased_at":null,"processes":[]}]
legacy teardown: /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T/fm-spawn-worktree-lease.B8cIbV/evidence-legacy/project: skipped: origin/main does not exist
teardown ship-evidence-legacy complete (window firstmate:fm-ship-evidence-legacy, worktree /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-legacy/wt)
Backlog: ship-evidence-legacy just finished. Update data/backlog.md - move ship-evidence-legacy to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due.
treehouse calls during legacy teardown:
treehouse return --force /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-legacy/wt
pool after legacy teardown: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.B8cIbV/evidence-legacy/wt","status":"available","lease_id":"test","lease_holder":"","leased_at":null,"processes":[]}]
Evidence: Scout lease and teardown CLI transcript
SCOUT SPAWN LEASE
leased worktree for scout-evidence-1
warn: no registry at /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.Lfj1FU/evidence-scout/home/data/projects.md; defaulting project to no-mistakes off
spawned scout-evidence-1 harness=codex kind=scout mode=no-mistakes yolo=off window=firstmate:fm-scout-evidence-1 worktree=/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.Lfj1FU/evidence-scout/wt
published metadata:
worktree=/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.Lfj1FU/evidence-scout/wt
kind=scout
treehouse_lease_holder=scout-evidence-1
treehouse_lease_state=held
pool after scout spawn: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.Lfj1FU/evidence-scout/wt","status":"leased","lease_id":"test","lease_holder":"scout-evidence-1","leased_at":null,"processes":[]}]
subsequent plain treehouse get exit=1 output=error: no available worktree (all leased)

SCOUT TEARDOWN
teardown scout-evidence-1 complete (window firstmate:fm-scout-evidence-1, worktree /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.Lfj1FU/evidence-scout/wt)
Backlog: scout-evidence-1 just finished. Update data/backlog.md - move scout-evidence-1 to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due.
treehouse calls:
treehouse return --force --if-lease-holder scout-evidence-1 /var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.Lfj1FU/evidence-scout/wt
pool after scout teardown: [{"name":"1","path":"/var/folders/bd/ky17fvwx45353ky4s2bg8ys80000gn/T//fm-spawn-worktree-lease.Lfj1FU/evidence-scout/wt","status":"available","lease_id":"test","lease_holder":"","leased_at":null,"processes":[]}]

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (6) ✅
  • 🚨 bin/fm-spawn.sh:1330 - Recovery treats a recorded directory's existence as proof that the task still owns it. Teardown returns the worktree at fm-teardown.sh:1462 but retains metadata when later Herdr endpoint confirmation fails at lines 1527-1531; that now-authorized recovery path re-enters an unleased slot which may already have been reassigned, recreating the data-loss failure. Complete refusal-capable cleanup before return or persist a returned/lease state that recovery validates; marker-absent legacy metadata can retain the explicitly required compatibility behavior.
  • 🚨 bin/fm-spawn.sh:1330 - The new recovery path is not serialized with teardown: spawn holds .spawn-<id>.lock, but teardown never acquires it. Teardown can therefore inspect the old metadata and return the same lease while recovery is entering that worktree, after which spawn publishes fresh metadata and launches in a pool slot available to another get. Make spawn recovery and teardown share the task lifecycle lock from metadata inspection through return and metadata retirement.
  • 🚨 bin/fm-spawn.sh:1338 - Host-side acquisition now requires treehouse get to print a path, but several existing spawn-test fakes still return success with empty stdout, including fm-tangle-guard, fm-gate-refuse, fm-busy-adapter-wiring, fm-grok-harness, fm-kimi-harness, and fm-backend. Those successful-spawn cases now exit here, and fm-tangle-guard additionally still asserts the removed pane-side treehouse get command. Update these fixtures and assertions to model lease acquisition and the subsequent pane cd.

🔧 Fix: Captain, harden durable worktree lease recovery
3 errors still open:

  • 🚨 bin/fm-teardown.sh:1351 - Forced secondmate cleanup bypasses the new child lifecycle protocol: it neither acquires $sub_state/.spawn-$child_id.lock nor reads the child's lease state/holder before an unguarded return. A child recovery can therefore validate and enter its leased worktree while parent teardown returns that slot, recreating the recovery/teardown race. Hold each child task lock from metadata inspection through retirement and use the same guarded state transitions; lease-marked return failures must fail closed instead of falling through to safe_rm_rf_child_worktree.
  • 🚨 bin/fm-spawn.sh:1711 - The new lease markers are written late in a truncating, non-atomic metadata publication. If spawn exits after writing worktree= but before both markers, abort cleanup returns the fresh lease, while the next spawn sees an empty lease state and misclassifies the partial metadata as authorized pre-change legacy, re-entering an available slot. Publish metadata through a same-directory temporary file plus atomic rename, and reject one-sided lease markers.
  • 🚨 bin/fm-teardown.sh:739 - Guarded abort and teardown now require treehouse return --if-lease-holder, but the repository still installs and accepts Treehouse v2.0.1, whose return command has no such option; bootstrap checks only get --lease. Consequently abort cleanup silently leaks fresh leases and successful teardown fails on the repository's supported CI pin. Raise the supported/pinned version to v2.1.0+ and capability-check every required lease flag consistently.

🔧 Fix: Captain, harden nested worktree lease lifecycle
1 error still open:

  • 🚨 bin/fm-teardown.sh:1179 - lock_firstmate_home_children locks only task IDs represented by the *.meta snapshot. A new child spawn can acquire .spawn-new.lock without metadata during or after this loop, publish before the later cleanup glob, and have its freshly held lease returned without teardown owning its lock; publishing after that glob instead lets parent retirement remove the owning home and durable record beneath it. Add a home-scoped retirement boundary checked atomically by every child spawn before leasing, establish it before enumeration, retain it through home cleanup, and require ownership of every processed child lock.

🔧 Fix: Captain, close child-spawn retirement race
1 warning still open:

  • ⚠️ bin/fm-spawn.sh:458 - The retirement boundary is acquired only after fm-wake-lib.sh has created $STATE and fm-guard.sh may have written home-local state. A child spawn can resolve its home, pause, let parent teardown return/remove it, then resume and recreate or mutate that retired—possibly reassigned—slot before observing the boundary. Acquire the boundary before any home-local initialization, or defer those side effects until it is held.

🔧 Fix: Captain, guard home before spawn initialization
1 warning still open:

  • ⚠️ bin/fm-spawn.sh:199 - Every spawn now takes the same nonblocking exclusive home boundary and holds it through guard, parsing, and backend setup. Two different task IDs started concurrently in one home therefore cause the second to fail here as “retirement” even when no teardown exists. Please confirm whether simultaneous distinct-task spawns must remain supported; if so, distinguish retirement ownership from transient spawn handoff, or wait and revalidate the home before proceeding.

🔧 Fix: Captain, atomically coordinate parallel spawns and retirement
1 error still open:

  • 🚨 bin/fm-spawn.sh:479 - The required lifecycle contract says “Retirement begin ... MUST succeed only when no active spawn is registered” and “Unregister ... only on success or abort paths,” but this hunk unregisters immediately after acquiring the task lock, before lease acquisition or metadata publication. Parent teardown can consequently begin retirement while spawn A is still active; only the later child-task-lock scan notices A, while the transient retirement marker incorrectly refuses concurrent spawn B. Keep the home registration through successful atomic metadata publication or abort so retirement rejects A inside the shared-record critical section.

🔧 Fix: Captain, retain registration through metadata publication
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Baseline identity and scope: git status --short --branch, git rev-parse HEAD, and git diff 2ef842cb530967264ba0f4835249f247b2c2fa40..9050bede312488ca9e3b356c0d5739ac0ee64e30
  • bash tests/fm-spawn-worktree-lease.test.sh
  • Real fm-spawn.sh/fm-teardown.sh against a deterministic Treehouse pool: leased allocation, blocked subsequent treehouse get, recovery without reacquisition, recovered-publication abort, refused teardown, successful return, and legacy unleased compatibility
  • fm-spawn.sh scout-evidence-1 … --scout, blocked subsequent treehouse get, then guarded scout teardown and slot release
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: Captain, export lifecycle channels and silence ShellCheck findings
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

sparkus added 9 commits August 3, 2026 13:07
Plain treehouse get left pool slots available, so a later spawn could
reuse a parked task's worktree and hard-reset unlanded work (kunchenguid#1441).

Ship and scout spawns now acquire with treehouse get --lease
--lease-holder <task-id>, cd the pane into that path, and keep
isolation assertions. Recovery reuses a recorded worktree without a
second lease. Successful teardown still returns (and thus releases);
refused teardown never reaches return, so the lease stays held.
Pre-lease unleased worktrees remain usable without adopting a lease.
@sparkus

sparkus commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Closed on the captain's order. This ticket was mis-scoped: briefed as a small change to lease worktrees at spawn, it grew to 34 files and ~2,000 lines touching every backend adapter, the install path, bootstrap and CI. Discarding rather than landing a fleet-wide rewrite of worktree acquisition on a bad estimate.

@sparkus sparkus closed this Aug 3, 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