Skip to content

fix(bin): stop a reused pool slot from becoming another agent's home - #24

Open
brchue-ux wants to merge 10 commits into
mainfrom
fm/worktree-pool-secondmate-collision
Open

fix(bin): stop a reused pool slot from becoming another agent's home#24
brchue-ux wants to merge 10 commits into
mainfrom
fm/worktree-pool-secondmate-collision

Conversation

@brchue-ux

Copy link
Copy Markdown
Owner

Intent

Two firstmate tasks (a ship task, then a scout) were each handed the treehouse pool worktree /home/bchue/.treehouse/firstmate-74329a/7/firstmate, which is not a free pool slot: it is the 'dictate' secondmate's persistent, durably-leased home. Teardown of the first task killed 12 of that secondmate's processes and returned its home 'to pool'; the scout then launched into the same home and took its session lock, blocking the secondmate from starting. The captain asked to (1) reproduce the actual root cause rather than guess at it, (2) close the real allocation gap rather than add a spawn-time collision check that only fires after allocation already happened, (3) add a regression test, (4) separately add a cheap teardown/spawn backstop when a recorded worktree= matches a registered secondmate home, and (5) sweep for any other secondmate home already double-booked the same way.

Root cause established empirically against treehouse v2.1.0 in an isolated scratch pool, not inferred: plain 'treehouse get' correctly SKIPS a leased worktree, 'prune' correctly skips it, and concurrent get/--lease writes do not clobber each other. But 'treehouse return' releases a lease for ANY caller unless given --if-lease-holder/--if-lease-id, and firstmate passed neither. Pool state snapshots confirm slot 7 was leased to 'dictate' on 2026-08-13 and is unleased now. The chain: an earlier task legitimately used slot 7 while it was free, its state meta was later hand-reconstructed (still pointing at slot 7) to work around a separate premature-reclaim bug, the home was leased on top of that stale record two days later, and tonight's teardown of the old task released the live lease. After that the slot was genuinely free, so the scout's ordinary acquisition was legal. So no treehouse bug is involved and a spawn-time-only check would indeed have been a band-aid.

Deliberate design decisions a reviewer should not mistake for oversights:

  • Ownership is decided from firstmate's own durable records (the .fm-secondmate-home marker plus data/secondmates.md), NOT from treehouse lease state. This is intentional: it keeps protecting a home whose lease has ALREADY been lost, which is the live condition of slot 7 right now, and of two further homes the sweep found.
  • --force does NOT bypass the home guard in teardown. --force authorizes discarding THIS task's work; it is never authority over another agent's home.
  • I deliberately did NOT switch teardown to treehouse's --if-lease-holder guard: an ordinary task worktree has no lease at all, and --if-lease-holder fails on an unleased worktree (verified, rc=1), so it cannot be applied blanket there.
  • I deliberately did NOT add an ownership guard to fm-home-seed.sh's rollback, and reverted an earlier attempt to. That path only runs with SEED_HOME_ACQUIRED=1, so the lease it releases is the one that seed took seconds earlier under its own id. An existing test (fm-secondmate-safety) correctly asserts that a seed handed a home marked for another secondmate returns it; guarding there would instead strand our own lease on a home no registry records. The reverted attempt is why that file carries only an explanatory comment.
  • The spawn-side pre-allocation lease check warns and does NOT block when pool state cannot be read (treehouse or jq missing). This is intentional layering: the unconditional, jq-free guarantees are the teardown guard and the post-allocation assertion; the pre-allocation gate is extra cover for leases lost outside firstmate, so making it hard-fail on a missing optional tool would break tmux setups without jq for no safety gain.
  • The audit reads each home's OWN pool records (/treehouse-state.json) and not only 'treehouse status --json'. This matters and is not redundancy: 'treehouse status' reports only the single pool the backing repo resolves to today, so an entire second pool of the same repo (firstmate-7bab20, 11 homes) was invisible to it. My first implementation reported those 11 homes as 'not pooled', a false all-clear; the fallback is what fixes that.
  • The audit distinguishes a linked worktree from a plain clone via git-dir vs git-common-dir, so a home seeded at an explicit path is correctly reported as having no lease to lose rather than as a problem.
  • fm_leased_home_pool_status emits a literal '-' for an absent lease holder rather than an empty field, because tab is IFS whitespace and bash 'read' collapses consecutive tabs, which silently shifted the path out of its variable. That is a fixed bug, not a stylistic choice.

The sweep (step 5) found two previously-unknown problems beyond the reported slot: secondmate 'explore' at firstmate-7bab20/9 has lost its lease, and 'herdr' at firstmate-7bab20/12 is a pool worktree its pool has no record of at all.

Mid-task the captain reported two further findings and asked me to check whether they share a root cause and cover them if feasible, which I did:

  • A pane-layer collision: two task records named the same live herdr pane, so a steer addressed to one secondmate was typed into another's session. I established this is the SAME identifier-reuse class but an INDEPENDENT gap with no shared code: fm-send already computes EXPECTED_LABEL='fm-' and passes it down, and zellij and cmux verify it, but the herdr adapter's fm_backend_herdr_send_text_submit does not even declare the parameter and silently drops it. I deliberately did NOT change the herdr adapter here: it is the fleet's default backend, threading the label through its send/capture path is high blast radius, and this fleet's standing rule is that herdr pane behavior must be verified against a running herdr, which is not possible from inside a task worktree without risking live panes. That is reported as a separate task. What I did add instead is backend-agnostic and safe: fm-send refuses when two live task records name the same endpoint, decided from firstmate's own records before any backend is consulted.
  • Stale turn-end hooks: a reused worktree still held a previous occupant's claude Stop hook, firing wakes for a task id that no longer exists. Spawn only ever wrote its own hook, which overwrites just the artifact its own harness uses, and a kind=secondmate spawn writes none at all, so a prior occupant's leftover was invisible to both paths. Spawn now actively clears foreign turn-end artifacts on every spawn of every kind. Files carrying no firstmate turn-end signature (for example a settings.local.json the captain wrote) are deliberately left alone, and .grok/hooks/fm-turn-end.json is deliberately not swept because it is task-agnostic and is disarmed by removing the .fm-grok-turnend pointer instead.

Verification done: tests/fm-leased-home.test.sh adds 17 cases across all three layers and I confirmed the teardown cases fail when the guard is disabled. bin/fm-lint.sh, bin/fm-doc-audience-check.sh, and the fm-teardown, fm-spawn-, fm-send-, and fm-secondmate-lifecycle suites all pass. One pre-existing failure in fm-secondmate-safety ('seed did not explain uninitialized existing no-mistakes clone refusal') was verified identical at baseline via git stash and is not caused by this change.

What Changed

  • Adds bin/fm-leased-home-lib.sh, a record-based ownership guard that identifies a secondmate home from its own .fm-secondmate-home marker and data/secondmates.md rather than from treehouse lease state, and wires it into both lifecycle ends: fm-teardown.sh refuses a task whose recorded worktree= names a home it does not own — before the stale-lock cleanup, branch deletion, and turn-end hook removal, and again inside every treehouse return — with --force deliberately not bypassing it, while fm-spawn.sh refuses to allocate from a pool holding an unprotected home (warn-only when pool state is unreadable) and re-asserts post-allocation in validate_spawn_worktree. Adds bin/fm-leased-home-audit.sh to report unleased, mismatched, untracked, or already-collided homes (falling back to each home's own treehouse-state.json so second pools of the same repo are visible), and bin/fm-collided-record-clear.sh as the only supported way to retire an already-collided record without touching the home.
  • Extracts the hardened PR-check, turn-end-authorization, and tasktmp= cleanup out of fm-teardown.sh into bin/fm-task-record-lib.sh so the new clear command reuses the same refusing protocol; adds bin/fm-turnend-artifact-lib.sh and has every spawn, of every kind, clear a previous occupant's turn-end hook artifacts (leaving unsigned files and the task-agnostic .grok/hooks/fm-turn-end.json alone).
  • Makes fm-send.sh refuse, on both the task-id and explicit-endpoint resolution paths and before any backend is consulted, when two live task records name the same endpoint; adds tests/fm-leased-home.test.sh (17 cases), routes the new paths through fm-test-run.sh family selection, and documents the guard, audit, and clear commands in docs/architecture.md, docs/configuration.md, docs/scripts.md, and the secondmate-provisioning skill. fm-home-seed.sh gains only a comment recording why its rollback path is deliberately unguarded.

Risk Assessment

⚠️ Medium: No substantiable defects remain after verifying each fix round against the current code, but the change adds hard refusals to the teardown and spawn paths every task uses and introduces a new destructive record-clearing command the captain will run against live at-risk homes, so the blast radius warrants a normal merge rather than a trivially safe one.

Testing

I reproduced the reported incident end-to-end against the real scripts rather than relying on unit assertions: in a scratch treehouse pool where slot 7 is secondmate 'dictate's home whose lease is already lost and a stale task record still names it, the base commit's fm-teardown.sh task-old --force completes and issues treehouse return --force on that home (also detaching its branch), while the changed code refuses with a message naming the secondmate and points at the audit, with no treehouse call other than a read-only status. The same transcript shows the pool-side spawn refused before any window or task record exists, fm-collided-record-clear.sh clearing only the stale record and leaving the home present, and the audit going from LOST_LEASE+COLLISION to LOST_LEASE alone. A second transcript covers the two mid-task findings on both commits: at baseline a steer addressed to 'dictate' was typed into the shared pane (TYPED INTO PANE sess:fm-shared: ... status please) and a reused slot kept the previous occupant's claude Stop hook pointing at first-occupant.turn-ended; after the change the send refuses with nothing typed and the foreign hook is gone while the new occupant's own hook is intact. The new 39-case suite passes and fails at the base commit on the primary teardown case, and ten related teardown/spawn/send/secondmate suites pass apart from one pre-existing fm-secondmate-safety charter-scaffold failure I confirmed identical at the base commit. No UI surface is involved — this change is entirely CLI/script behavior, so the evidence is command transcripts rather than screenshots. All scratch fixtures self-clean; the worktree is clean and evidence lives only under the evidence directory.

Evidence: Incident reproduction — AFTER the change (teardown refuses, home survives, record cleared safely)

=== STEP 1 $ fm-leased-home-audit.sh === LOST_LEASE: dictate .../firstmate-74329a/7/firstmate is in-use, so an ordinary acquisition can take it COLLISION: task task-old records worktree=.../7/firstmate, the home of dictate exit=1 === STEP 2 $ fm-teardown.sh task-old --force === REFUSED: teardown of task task-old targets .../7/firstmate, the persistent home of secondmate 'dictate'. That home is leased from the same pool as task worktrees; releasing or reusing it drops the lease and hands the home to the next ordinary task. Run bin/fm-leased-home-audit.sh to see which homes have already lost their lease. This task record is already collided and cannot be retired here at all. Clear the record alone - leaving that home untouched - with bin/fm-collided-record-clear.sh task-old. exit=1 dictate's home: PRESENT (marker: dictate), branch fm/task-old treehouse calls: treehouse status --json| === STEP 3 $ fm-spawn.sh scout-task <project> === REFUSED: secondmate 'dictate' home .../7/firstmate has lost its pool lease, so an ordinary worktree acquisition can be handed that home. Refusing to acquire a worktree from this pool until the lease is restored; see bin/fm-leased-home-audit.sh. exit=1 task record written: no === STEP 4 $ fm-collided-record-clear.sh task-old === cleared .../state/task-old.meta cleared the collided record for task task-old; secondmate 'dictate' home .../7/firstmate was not touched. That record named endpoint firstmate:fm-task-old; close it yourself if it is still open. exit=0 stale record state/task-old.meta: cleared dictate's home: PRESENT (marker: dictate), branch fm/task-old === STEP 5 $ fm-leased-home-audit.sh (re-run) === LOST_LEASE: dictate .../7/firstmate is in-use, so an ordinary acquisition can take it exit=1 (collision gone)


=== STEP 1  $ fm-leased-home-audit.sh   (the diagnostic every refusal points at) ===
  LOST_LEASE: dictate /tmp/fm-incident.WWSrbt/.treehouse/firstmate-74329a/7/firstmate is in-use, so an ordinary acquisition can take it
  COLLISION: task task-old records worktree=/tmp/fm-incident.WWSrbt/.treehouse/firstmate-74329a/7/firstmate, the home of dictate
  exit=1

=== STEP 2  $ fm-teardown.sh task-old --force   (the command that caused the incident) ===
  REFUSED: teardown of task task-old targets /tmp/fm-incident.WWSrbt/.treehouse/firstmate-74329a/7/firstmate, the persistent home of secondmate 'dictate'.
  That home is leased from the same pool as task worktrees; releasing or reusing it drops the lease and hands the home to the next ordinary task.
  Run bin/fm-leased-home-audit.sh to see which homes have already lost their lease.
  This task record is already collided and cannot be retired here at all. Clear the record alone - leaving that home untouched - with bin/fm-collided-record-clear.sh task-old.
  exit=1
dictate's home: PRESENT (marker: dictate), branch fm/task-old
treehouse calls: treehouse status --json|

=== STEP 3  $ fm-spawn.sh scout-task <project>   (next task tries to allocate from that pool) ===
  REFUSED: secondmate 'dictate' home /tmp/fm-incident.WWSrbt/.treehouse/firstmate-74329a/7/firstmate has lost its pool lease, so an ordinary worktree acquisition can be handed that home.
  Refusing to acquire a worktree from this pool until the lease is restored; see bin/fm-leased-home-audit.sh.
  exit=1
task record written: no

=== STEP 4  $ fm-collided-record-clear.sh task-old   (the supported way out) ===
  cleared /tmp/fm-incident.WWSrbt/state/task-old.meta
  cleared the collided record for task task-old; secondmate 'dictate' home /tmp/fm-incident.WWSrbt/.treehouse/firstmate-74329a/7/firstmate was not touched.
  That record named endpoint firstmate:fm-task-old; close it yourself if it is still open.
  exit=0
stale record state/task-old.meta: cleared
dictate's home: PRESENT (marker: dictate), branch fm/task-old
treehouse calls: treehouse status --json|treehouse status --json|

=== STEP 5  $ fm-leased-home-audit.sh   (re-run: the collision is gone) ===
  LOST_LEASE: dictate /tmp/fm-incident.WWSrbt/.treehouse/firstmate-74329a/7/firstmate is in-use, so an ordinary acquisition can take it
  exit=1
Evidence: Same scenario at base commit 8796b71 — the incident happening

=== BASE COMMIT: $ fm-teardown.sh task-old --force === teardown task-old complete (window firstmate:fm-task-old, worktree .../firstmate-74329a/7/firstmate) exit=0 dictate's home: PRESENT (marker: dictate), branch HEAD <- branch detached treehouse calls: treehouse return --force .../firstmate-74329a/7/firstmate| <- the lease released


=== BASE COMMIT: operator tears down the stale record  $ fm-teardown.sh task-old --force ===
  /tmp/fm-incident.oibz2U/project: skipped: no origin remote
  teardown task-old complete (window firstmate:fm-task-old, worktree /tmp/fm-incident.oibz2U/.treehouse/firstmate-74329a/7/firstmate)
  Backlog: task-old just finished. Run tasks-axi done task-old --pr PR_URL, then run tasks-axi ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due.
  exit=0
dictate's home: PRESENT (marker: dictate), branch HEAD
treehouse calls: treehouse return --force /tmp/fm-incident.oibz2U/.treehouse/firstmate-74329a/7/firstmate|
Evidence: Endpoint + turn-end reuse collisions — AFTER the change

=== A $ fm-send.sh dictate 'status please' (two records name sess:fm-shared) === error: endpoint sess:fm-shared is recorded by both .../state/dictate.meta and .../state/herdr-twin.meta, so a message for one task would land in the other's live session; reconcile the stale record before steering exit=1 keystrokes delivered to any session: none - nothing was typed === B real spawn takes pool slot 1, later task reuses the same slot === after spawn of first-occupant (harness claude), the slot holds: .claude/settings.local.json -> .../state/first-occupant.turn-ended after the slot is reused by second-occupant (harness opencode): .claude/settings.local.json (previous occupant) -> absent .opencode/plugins/fm-turn-end.js (new occupant) -> .../state/second-occupant.turn-ended


=== A  $ fm-send.sh dictate 'status please'   (two records name endpoint sess:fm-shared) ===
  ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ●  WATCHER DOWN - SUPERVISION IS OFF - down for an unknown time
  ●  2 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
  ●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
  ●  This is a supervision warning only; the requested message WILL still be sent.
  ●  repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task, never shell &; it attaches instead of disturbing a watcher that is already healthy.
  ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  error: endpoint sess:fm-shared is recorded by both /tmp/fm-reuse.Cb7CT7/send/state/dictate.meta and /tmp/fm-reuse.Cb7CT7/send/state/herdr-twin.meta, so a message for one task would land in the other's live session; reconcile the stale record before steering (tried meta=/tmp/fm-reuse.Cb7CT7/send/state/dictate.meta; backend=from-meta)
  exit=1
keystrokes delivered to any session: none - nothing was typed

=== B  a real spawn takes pool slot 1, then a later task reuses the same slot ===
  after spawn of first-occupant (harness claude), the slot holds:
    .claude/settings.local.json -> /tmp/fm-reuse.Cb7CT7/spawn/home/state/first-occupant.turn-ended
  after the slot is reused by second-occupant (harness opencode):
    .claude/settings.local.json (previous occupant) -> absent
    .opencode/plugins/fm-turn-end.js (new occupant) -> /tmp/fm-reuse.Cb7CT7/spawn/home/state/second-occupant.turn-ended
Evidence: Endpoint + turn-end reuse collisions at base commit 8796b71 — both defects visible

=== A $ fm-send.sh dictate 'status please' === error: text not submitted to sess:fm-shared (delivery unconfirmed; verdict=pending) exit=1 keystrokes delivered to any session: TYPED INTO PANE sess:fm-shared: [fm-from-firstmate] corr=e3b3c6aa00a5c548 status please TYPED INTO PANE sess:fm-shared: Enter <- the steer landed in the wrong secondmate's session === B slot reuse === after the slot is reused by second-occupant (harness opencode): .claude/settings.local.json (previous occupant) -> .../state/first-occupant.turn-ended <- stale hook survives .opencode/plugins/fm-turn-end.js (new occupant) -> .../state/second-occupant.turn-ended


=== A  $ fm-send.sh dictate 'status please'   (two records name endpoint sess:fm-shared) ===
  ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ●  WATCHER DOWN - SUPERVISION IS OFF - down for an unknown time
  ●  2 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
  ●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
  ●  This is a supervision warning only; the requested message WILL still be sent.
  ●  repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task, never shell &; it attaches instead of disturbing a watcher that is already healthy.
  ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  error: text not submitted to sess:fm-shared (delivery unconfirmed; verdict=pending; tried meta=/tmp/fm-reuse.Cpy8r0/send/state/dictate.meta; backend=from-meta)
  exit=1
keystrokes delivered to any session: TYPED INTO PANE sess:fm-shared: [fm-from-firstmate]⁣corr=e3b3c6aa00a5c548 status please
TYPED INTO PANE sess:fm-shared: Enter
TYPED INTO PANE sess:fm-shared: Enter
TYPED INTO PANE sess:fm-shared: Enter

=== B  a real spawn takes pool slot 1, then a later task reuses the same slot ===
  after spawn of first-occupant (harness claude), the slot holds:
    .claude/settings.local.json -> /tmp/fm-reuse.Cpy8r0/spawn/home/state/first-occupant.turn-ended
  after the slot is reused by second-occupant (harness opencode):
    .claude/settings.local.json (previous occupant) -> /tmp/fm-reuse.Cpy8r0/spawn/home/state/first-occupant.turn-ended
    .opencode/plugins/fm-turn-end.js (new occupant) -> /tmp/fm-reuse.Cpy8r0/spawn/home/state/second-occupant.turn-ended
Evidence: Regression suite is red before the fix, green after

$ git checkout 8796b713 -- bin/ && bin/fm-test-run.sh tests/fm-leased-home.test.sh not ok - marker-home teardown: expected exit 1, got 0 FM_TEST_SUMMARY total=1 failed=1 $ git checkout c994f646 -- bin/ && bin/fm-test-run.sh tests/fm-leased-home.test.sh 39 ok cases, FM_TEST_SUMMARY total=1 failed=0 duration_ms=6996

# Regression proof: the new suite is red at the base commit and green after the fix

$ git checkout 8796b713 -- bin/ && bin/fm-test-run.sh tests/fm-leased-home.test.sh
  not ok - marker-home teardown: expected exit 1, got 0
  FM_TEST_SUMMARY total=1 failed=1

$ git checkout c994f646 -- bin/ && bin/fm-test-run.sh tests/fm-leased-home.test.sh
  39 ok cases, FM_TEST_SUMMARY total=1 failed=0 duration_ms=6996
- Outcome: ⚠️ 1 info across 1 run (8m52s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 6 issues found → auto-fixed (6) ✅
  • ⚠️ bin/fm-teardown.sh:1167 - The secondmate-home guard only runs inside teardown_treehouse_return, but the ordinary-task branch already mutates the target before calling it: lines 1167-1172 run git checkout --detach + git branch -D when the target is on a branch, and line 1173-1175 unconditionally rm -fs .claude/settings.local.json, .opencode/plugins/fm-turn-end.js, .fm-grok-turnend and .fm-kimi-turnend. In the exact reported scenario (a stale worktree= naming a live secondmate home) teardown therefore deletes that secondmate's turn-end hook artifacts - silently stopping its turn-end wakes - and only then prints REFUSED, so the operator reads the refusal as 'nothing was touched'. Real pool homes are detached HEAD today so branch -D is usually skipped, but a home seeded at an explicit path (plain clone on a branch) would lose that branch. --force does not change this: both hunks run before the guard either way. The new comment at bin/fm-teardown.sh:617 ("Refuse before the first destructive command") is not true as placed; cleanup_stale_lock_for_safety_check can also remove a .git index.lock inside the home earlier still. Fix: call fm_leased_home_guard "$WT" "" ... "$SECONDMATE_REG" once before the branch/hook block (or hoist it near the top of the teardown flow) and keep the in-function guard as the backstop.
  • ⚠️ bin/fm-leased-home-lib.sh:70 - fm_leased_home_registry_entries emits its TSV with sed -n &#39;s/.../\1\t\2/p&#39;. \t in the replacement is a GNU sed extension; BSD/macOS sed substitutes a literal t, so each line comes back as "<id>t<path>" in one field. Every consumer then drops it (IFS=$&#39;\t&#39; read -r entry_id entry_home leaves entry_home empty and the loops continue), which silently disables: the registry half of fm_leased_home_owner (teardown and spawn fall back to marker-only identity), fm_leased_home_unprotected (spawn's pre-allocation gate always sees an empty list and allows), and all of fm-leased-home-audit.sh, which then prints "OK: no registered secondmate homes to audit" and exits 0 - a false all-clear of exactly the kind the intent calls out. macOS is a supported platform (README badge, orca/cmux backends, the macos-stock-bash CI job), and that job only parse-checks scripts and runs the snapshot suites, so tests/fm-leased-home.test.sh case (b) would never run there to catch it. Fix: build a literal tab once (tab=$(printf &#39;\t&#39;)) and interpolate it, or switch the extractor to awk.
  • ⚠️ bin/fm-spawn.sh:1326 - assert_pool_has_no_unprotected_home runs at line 1326, after the case $BACKEND block has already created the tmux window / herdr tab / cmux task, and spawn_abort_cleanup (bin/fm-spawn.sh:362) only tears down orca worktrees and locks - it never closes a non-orca endpoint. So every refusal by this gate leaks an orphan pane/tab. That is not a rare path: against /home/bchue/projects/firstmate the pool reports slot 7 (registered home of 'dictate') as in-use and unleased right now, so this gate refuses every ship/scout spawn from that primary until the lease is restored, leaving a stray window behind on each attempt. The refusal itself is the intended design; the ordering is the defect. The check needs only $PROJ and $DATA, both known long before backend creation, so hoisting it above the case $BACKEND block makes the refusal side-effect-free.
  • ⚠️ bin/fm-leased-home-lib.sh:55 - fm_leased_home_marker_id does IFS= read -r id &lt; &#34;$marker&#34; 2&gt;/dev/null || return 1. bash's read returns 1 when it hits EOF without the delimiter, so a .fm-secondmate-home written without a trailing newline (a hand-repaired marker, printf &#39;%s&#39;, echo -n) makes the predicate return failure even though id was populated - the guard fails open and the home is treated as an ordinary worktree. Every pre-existing reader (bin/fm-teardown.sh:926, bin/fm-home-seed.sh:551) uses cat and tolerates it, so this is a new, stricter reader in the safety path. Fix: IFS= read -r id &lt; &#34;$marker&#34; 2&gt;/dev/null || [ -n &#34;$id&#34; ] || return 1.
  • ℹ️ bin/fm-test-run.sh:977 - The reverse mapping was added (bin/fm-leased-home* -> secondmate/pr-forge/backend-dispatch) but not the forward one: bin/fm-teardown.sh still maps only to pr-forge, and the bin/* basename fallback matches fm-teardown*.test.sh, not fm-leased-home.test.sh. Since the guard's most consequential layer lives in teardown's return path, a future teardown-only edit that moves or drops fm_leased_home_guard would not run the suite that locks it down. The repo already has the mechanism for this - see the __script__:fm-tmp-usage.test.sh selection under bin/fm-guard.sh - so giving bin/fm-teardown.sh its own arm with __script__:fm-leased-home.test.sh closes it without over-selecting the shared pr-forge arm.
  • ℹ️ bin/fm-spawn.sh:935 - tests/fm-leased-home.test.sh covers the teardown, audit, send and hook layers behaviorally, but neither new spawn refusal has a case: assert_pool_has_no_unprotected_home (a deterministic fleet-wide hard fail whose blast radius is every ship/scout spawn) and validate_spawn_worktree's post-allocation home refusal. Both are drivable with the fixtures already in the file - a fake treehouse plus a registry entry for a pool slot reported unleased should make spawn exit 1 and report the home, and a warn-not-block case with treehouse/jq absent would pin the deliberate non-blocking behavior the intent describes.

🔧 Fix: harden secondmate-home guard ordering, parsing, and spawn tests
4 issues (1 error, 2 warnings, 1 info) still open:

  • 🚨 bin/fm-teardown.sh:1075 - On the child-worktree path, the new ownership guard's refusal is indistinguishable from a failed treehouse return, and the fallback for that is rm -rf. Concrete sequence: cleanup_firstmate_home_children (reached by a kind=secondmate teardown, line 1053 nested or line 1121 under --force) iterates the home's state/*.meta; for an ordinary child whose recorded worktree= names a DIFFERENT secondmate's home (the exact stale-record class this change exists to close - the sweep found three such homes), line 1068 calls teardown_treehouse_return &#34;$child_wt&#34; &#34;$child_proj&#34; &#34;child worktree&#34; with no owner id. fm_leased_home_guard prints REFUSED and returns 1. The caller only propagates TEARDOWN_TREEHOUSE_LOCK_REFUSED (=2, bin/fm-teardown.sh:561), so rc=1 falls to safe_rm_rf_child_worktree at line 1075, which passes validate_child_worktree_for_removal whenever git -C &#34;$child_proj&#34; worktree list names the target - and it does whenever the child's project shares the home's git common dir, which is the premise of this whole change (homes and task worktrees come from one pool of one repo). Result: the guard that was added to stop a lease release instead deletes the home outright. Separately and unconditionally, lines 1065-1066 rm -f .claude/settings.local.json, .opencode/plugins/fm-turn-end.js, .fm-grok-turnend and .fm-kimi-turnend in that path BEFORE the guard runs, so even when the rm -rf is blocked the foreign home silently loses its turn-end wakes - the same defect just fixed for the ordinary-task path. Fix: give the guard refusal its own return code (as the lock refusal has) and have the child loop skip that child instead of falling back to removal, and hoist the ownership check above the rm -f at 1065 in the same way it was hoisted in the ordinary-task path.
  • ⚠️ bin/fm-spawn.sh:1034 - assert_pool_has_no_unprotected_home &#34;$PROJ&#34; passes the raw project argument. $PROJ is ${POS[1]} verbatim (line 460) and the documented batch/spawn form is projects/&lt;name&gt; (see the usage example at bin/fm-spawn.sh:112), which only becomes a real directory after resolve_project_dir_arg maps it to $PROJECTS/&lt;name&gt;. fm_leased_home_pool_status does ( cd &#34;$project&#34; &amp;&amp; treehouse status --json ), so for a projects/foo argument the cd resolves relative to whatever directory the captain ran fm-spawn from: it succeeds only by accident when cwd happens to be $FM_HOME, and otherwise returns 1. The gate then takes its warn-and-skip branch, printing warning: could not read the treehouse pool for projects/foo on every such spawn while silently providing no protection - the layer reads as active but is inert, and the warning is a false alarm rather than a real "pool state unreadable" signal. PROJ_ABS is already resolved at line 870, long before this call site. Fix: pass $PROJ_ABS. The new tests miss this because run_spawn always passes an absolute path.
  • ⚠️ bin/fm-leased-home-lib.sh:59 - IFS= read -r id &lt; &#34;$marker&#34; 2&gt;/dev/null || [ -n &#34;$id&#34; ] || return 1 references $id on a path where it can still be unset. local dir=$1 marker id declares id local and UNSET (not empty), and when the redirection itself fails - marker present for [ -f ] but not readable, or unlinked between the test and the read - read never runs and never assigns, so [ -n &#34;$id&#34; ] triggers set -u's "id: unbound variable". Verified on bash 5.3: the empty-file and no-trailing-newline cases behave as intended, the unreadable-file case aborts. Because the only caller is id=$(fm_leased_home_marker_id &#34;$abs&#34;) the abort kills the command substitution rather than the script, so the observable effect is a raw bash error on stderr plus the marker identity being silently dropped, leaving the guard to fall back to the registry alone. Fix: declare id=&#39;&#39; in the local line.
  • ℹ️ tests/fm-leased-home.test.sh:285 - The three new spawn cases cover an unleased home in the pool (t), unreadable pool state (u), and an empty pool plus a marker (v) - but none drives the pre-allocation gate against a pool where a registered home IS correctly leased to its own id in the live treehouse status --json listing. That is the only case that pins the [ &#34;$status&#34; != leased ] comparison in fm_leased_home_unprotected, and it matters because real treehouse v2.1.0 reports status:&#34;leased&#34; (not &#34;in-use&#34;) for a leased slot even when processes are running: if that comparison ever drifted, assert_pool_has_no_unprotected_home would hard-fail every ship and scout spawn into any pool containing a secondmate home, with no test catching it. Case (t)'s fixture already has everything needed - flip the fake entry to &#34;status&#34;:&#34;leased&#34;,&#34;lease_holder&#34;:&#34;dictate&#34; and assert the spawn completes without REFUSED.

🔧 Fix: stop a foreign-home refusal from deleting the home
7 issues (4 warnings, 3 infos) still open:

  • ⚠️ bin/fm-leased-home-audit.sh:104 - When jq or treehouse is unavailable, the audit reports every linked-worktree home as UNTRACKED and exits 1. fm_leased_home_pool_status returns 1 without jq/treehouse (bin/fm-leased-home-lib.sh:155-157) and fm_leased_home_pool_state_record returns 1 without jq (line 190), so found_status stays empty and line 104 falls straight to the UNTRACKED problem branch - "is a pool worktree its pool has no record of, so its slot can be reallocated" - which is a definite claim about pool state the script never actually read. The documented UNKNOWN line (bin/fm-leased-home-audit.sh:33, "UNKNOWN: <id> <home> pool state could not be read") is never emitted by any code path; the only UNKNOWN is the missing-directory case at line 82, whose text does not match the documented contract. This matters because the audit is the remedy every new refusal points operators at (fm_leased_home_guard's last line, assert_pool_has_no_unprotected_home's last line, and the SKILL.md text added in this change), so on a jq-less box the recommended diagnostic answers a real refusal with a fleet-wide false alarm. The new tests cannot catch it: tests/fm-leased-home.test.sh:558 exits the whole audit section when jq is absent. Fix: distinguish "no record found" from "pool state unreadable" - have audit_home track whether either reader actually succeeded, and emit the documented UNKNOWN when neither could be consulted.
  • ⚠️ bin/fm-teardown.sh:177 - The new early guard makes an already-collided task record unretireable through any supported command. bin/fm-teardown.sh is the only thing in bin/ that removes state/<id>.meta (line 1310; bin/fm-idle-sweep.sh:383 only removes markers whose meta is already gone), and line 177 exits 1 before any of that whenever the recorded worktree= resolves to a foreign home - deliberately including --force. The user intent states the sweep already found this exact population ('explore' at firstmate-7bab20/9 and 'herdr' at firstmate-7bab20/12, plus the reported slot 7), so these are live records, not a hypothetical. The refusal text (bin/fm-leased-home-lib.sh:133-139) names the audit script, which reports the home's lease state but says nothing about the stale task record, so an operator hitting this has no documented next step and is left to hand-edit state/<id>.meta - which is precisely the hand-reconstruction that produced the original bug. The guard itself is correct and should stay; what is missing is a supported way out. Minimum fix is message-only and does not weaken the guard: when the caller is an ordinary task (empty owner id), name the concrete remedy - correct or drop the worktree= line in state/<id>.meta, then re-run teardown. Whether to go further and add an explicit record-only teardown is the author's call, which is why this is flagged rather than fixed.
  • ⚠️ bin/fm-leased-home-lib.sh:165 - fm_leased_home_pool_status guards .lease_holder against emptiness with a literal '-' but leaves .status unguarded in the same @TSV array. The function's own comment (lines 161-163) states the reason the middle field is guarded: tab is IFS whitespace, so bash read collapses consecutive tabs and an empty field shifts every later field out of its variable. The first field carries the identical hazard - a leading empty field is also stripped as IFS whitespace. If treehouse ever emits an entry with status absent or null (its state file already omits leased entirely for an unleased slot, as tests/fm-leased-home.test.sh:596 mirrors), the consumer at bin/fm-leased-home-lib.sh:216 reads status=<holder>, holder=<path>, path="" and the [ -n &#34;$path&#34; ] || continue drops the entry. The effect is fail-open in the safety direction the intent calls out: an unleased registered home in that pool is silently reported as protected and assert_pool_has_no_unprotected_home lets the spawn through, and bin/fm-leased-home-audit.sh:97 likewise never matches it in the live listing. Fix: apply the same (if (.status // &#34;&#34;) == &#34;&#34; then &#34;-&#34; else .status end) treatment to the first field, exactly as the lease-holder field already has.
  • ⚠️ bin/fm-send.sh:199 - The ambiguous-endpoint refusal is applied only on the task-selector branch (line 167). The explicit-endpoint branch at lines 199-212 resolves a raw target through fm_backend_meta_for_window, which returns the FIRST meta whose window= or terminal= matches (bin/fm-backend.sh:538-549) and then sends to it with no conflict check at all. That branch is reachable exactly when the caller passes the endpoint itself - fm-send.sh &lt;herdr-session&gt;:&lt;pane-id&gt; &#34;...&#34; or fm-send.sh &lt;tmux-session&gt;:fm-&lt;id&gt; &#34;...&#34; - which is the natural way to address a pane you can see, and it is the same identifier-reuse class: two live records naming one endpoint, glob order silently picking one. The user intent states the added behavior as "fm-send refuses when two live task records name the same endpoint, decided from firstmate's own records before any backend is consulted"; on this path it does not. The predicate is already factored (fm_send_conflicting_meta_for_target) and takes the resolved target plus the owning meta, so extending it here is the same two lines - but it adds a user-visible refusal to a second entry point, so it is the author's scope call rather than a mechanical fix.
  • ℹ️ bin/fm-teardown.sh:1047 - When the child sweep skips a foreign home, teardown prints REFUSED and then exits 0. The guard at line 1045 continues, cleanup_firstmate_home_children returns 0, and the top-level call at line 1144 has nothing to propagate, so the run continues through remove_firstmate_home and exits successfully. Every other REFUSED in this script exits 1, so any wrapper reading the exit status sees an unqualified success for a teardown that declined part of its work. There is also a second-order effect the message contradicts: it says "Leaving child task $child_id and its recorded worktree in place", but the very next phase returns the PARENT home to the pool (line 1291), and treehouse's reset takes the home's state/ directory - including the child record the operator was just told to reconcile - with it. tests/fm-leased-home.test.sh:232 captures rc from this exact scenario and never asserts on it, so neither the current exit status nor a future change to it is pinned. Whether the right answer is a non-zero exit, a reworded message, or leaving it as-is is a behavior decision.
  • ℹ️ tests/fm-leased-home.test.sh:505 - fm_turnend_artifact_task_id parses artifact formats that bin/fm-spawn.sh:1432-1439 writes, but make_hook_worktree hand-copies those literal shapes into the fixture ("The literal shapes are copied from that script's hook block") instead of driving a real spawn. Nothing ties the writer to the reader: if spawn's claude Stop-hook JSON or the opencode plugin body changes shape - different quoting around the path, a different touch invocation - the sed at bin/fm-turnend-artifact-lib.sh:50 stops matching, fm_turnend_clear_foreign silently identifies no owner and clears nothing, and cases (o,p,q) still pass because they assert against the stale copy. The sweep failing open is the exact defect it exists to prevent. The spawn fixtures in this same file already drive the real script end to end (cases t/u/v/w/y), so the cheap close is one case that plants a foreign hook in the pool slot spawn is about to be handed and asserts the completed spawn removed it.
  • ℹ️ bin/fm-teardown.sh:1046 - The child-sweep guard and the teardown_treehouse_return backstop pass only $SECONDMATE_REG (the primary's data/secondmates.md). The pre-existing precedent one function up handles this differently: validate_firstmate_home_for_removal consults $SECONDMATE_REG and then falls back to $abs_home_path/data/secondmates.md (lines 958-961), because a home being retired can itself register grandchild homes that the primary's registry has never seen. In the sweep at line 1046 the parent home is $home and its registry at $home/data/secondmates.md is not consulted, so the registry half of the identity check is blind to a grandchild home registered only there. The marker half still catches any properly seeded home, which is why this is informational rather than a hole - but the intent explicitly relies on the registry as the independent second source for homes whose marker was hand-repaired or lost, and that is the population where the two paths now disagree. Adding "$home/data/secondmates.md" as a second registry argument at line 1046 restores symmetry with line 960.

🔧 Fix: close audit, send, and child-sweep gaps in the home guard
3 issues (2 warnings, 1 info) still open:

  • ⚠️ bin/fm-leased-home-lib.sh:199 - fm_leased_home_pool_state_record documents "Returns 1 when the pool has no record of the home ... and 2 when those records could not be consulted at all", but its last command is jq -er ... &#34;$state&#34;, so it returns jq's exit code verbatim. Verified on jq 1.8.1: a valid state file with no matching path exits 4 (correct: no record), but a state file whose .worktrees key is absent or null exits 5, and a truncated or partially written state file also exits 5 - not 2. bin/fm-leased-home-audit.sh:115 only treats rc 2 as unreadable ([ &#34;$record_rc&#34; -eq 2 ] || consulted=1), so a pool state file caught mid-write, or a treehouse schema change, sets consulted=1 and line 123 reports every home in that pool as "UNTRACKED: <id> <home> is a pool worktree its pool has no record of, so its slot can be reallocated" and exits 1. That is a definite claim about pool state the script never read, and it is the fleet-wide false alarm the UNKNOWN branch added in this same commit exists to prevent - on the one diagnostic that fm_leased_home_guard, assert_pool_has_no_unprotected_home and the new SKILL.md text all point operators at. The audit tests cannot catch it: tests/fm-leased-home.test.sh:735 exercises only the no-state-file path, which takes the function's own return 2. Fix: capture jq's status and map it - rc 0 is an answer, rc 4 (and 1) is "answered, no record", every other rc is "could not consult" and must return 2.
  • ⚠️ bin/fm-collided-record-clear.sh:87 - The suffix loop deletes $STATE/$ID.grok-turnend-token and $STATE/$ID.kimi-turnend-token with a plain rm -f, but never removes the authorization file those tokens name. bin/fm-teardown.sh pairs every removal of those records with remove_grok_turnend_auth / remove_kimi_turnend_auth (bin/fm-teardown.sh:234-248), which read the token out of the record and then rm -f &#34;${GROK_HOME:-$HOME/.grok}/hooks/fm-turn-end.d/$token&#34; (and $HOME/.kimi-code/fm-turn-end.d/$token), and teardown deliberately calls them at line 1319-1320 BEFORE the rm at line 1327. Concrete sequence: a collided grok task record is cleared with this command; spawn had written .fm-grok-turnend into the foreign home pointing at that token, and this command deliberately does not touch the home, so the pointer stays. The token record is now gone, but the auth file remains and still authorizes a touch of state/<id>.turn-ended for a task id that no longer exists - and because the token string lived only in the record just deleted, nothing can ever locate that file again to remove it. That is the same dead-task-wake class this change adds fm-turnend-artifact-lib.sh to eliminate, made permanent. Fix: read the token and remove the hooks-dir file (same four lines as remove_grok_turnend_auth / remove_kimi_turnend_auth) before the record is unlinked.
  • ℹ️ bin/fm-collided-record-clear.sh:87 - The same loop deletes $ID.check.sh, $ID.pr-poll, $ID.pr-poll-registration, $ID.pr-poll-retirement and $ID.check-trust with a bare rm -f, bypassing the only hardened path the repo has for those exact files. teardown routes them through remove_pr_poll_artifacts (bin/fm-teardown.sh:312-329), which first runs validate_pr_poll_cleanup - refusing with "REFUSED: unsafe task PR-check artifact; preserving task state" when one of them is a symlink, is hardlinked, or sits on a different device - then fm_pr_poll_retirement_recover_one for the identity-and-hash-verified retirement protocol, and finally sweeps $STATE/.pr-check-quarantine/$ID.*. This command does none of those. Since teardown of a collided record now refuses unconditionally, this is the only remaining owner of that id, so any quarantined artifacts for it are orphaned permanently with no command left that can remove them. The script already sources bin/fm-pr-lib.sh, so fm_pr_poll_retirement_recover_one is in scope; the quarantine sweep is the four lines at bin/fm-teardown.sh:320-327.

🔧 Fix: share teardown's hardened record cleanup with the clear command
4 issues (2 warnings, 2 infos) still open:

  • ⚠️ bin/fm-turnend-artifact-lib.sh:42 - The grok/kimi branch of fm_turnend_artifact_task_id parses a format bin/fm-spawn.sh never writes, so those pointers are never swept. Spawn writes printf &#39;token=%s\n&#39; &#34;${auth_file##*/}&#34; into $WT/.fm-grok-turnend and $WT/.fm-kimi-turnend (bin/fm-spawn.sh:1521 and 1527), where auth_file is mktemp &#34;$GROK_AUTH_DIR/fm.XXXXXXXXXXXX&#34; - so the pointer content is token=fm.aaaabbbbcccc. The parser strips token=, takes the basename, then requires the value to end in .grok-turnend-token / .kimi-turnend-token, which is the name of the state RECORD ($STATE/<id>.grok-turnend-token), not the pointer's content. Neither case arm matches, nothing is printed, fm_turnend_clear_foreign's [ -n &#34;$owner&#34; ] || continue skips the file, and it survives. Verified directly against the shipped lib: a file containing token=fm.aaaabbbbcccc yields owner=[] and survives fm_turnend_clear_foreign. Reachable path: a pool slot whose previous grok occupant's teardown never ran (the exact class this change exists to close) still holds both .fm-grok-turnend and its auth file under ~/.grok/hooks/fm-turn-end.d/; the next occupant spawns with a different harness, spawn's own hook write touches only that harness's artifact, the sweep skips the pointer, and the global grok Stop hook keeps resolving the pointer to the auth file and touching the dead task's state/<id>.turn-ended on every turn - the dead-task wake the lib exists to eliminate. The intent states this as delivered behavior: "Spawn now actively clears foreign turn-end artifacts on every spawn of every kind", with .grok/hooks/fm-turn-end.json named as the ONLY deliberate exclusion because "it is disarmed by removing the .fm-grok-turnend pointer instead" - the pointer that is not in fact removed. Fix: decide ownership from the token that spawn really writes, e.g. treat the pointer as foreign when its token does not match the content of $STATE/$ID.<harness>-turnend-token, or resolve the auth file and read the turn-ended path it holds (the same shape the claude/opencode arm already parses).
  • ⚠️ tests/fm-leased-home.test.sh:757 - make_hook_worktree writes token=$owner.grok-turnend-token / token=$owner.kimi-turnend-token into the pointer files, which is not the shape bin/fm-spawn.sh:1521 and 1527 produce (token=fm.XXXXXXXXXXXX). Cases (o,p,q) at lines 763-774 therefore assert that fm_turnend_artifact_task_id resolves an owner and that the sweep removes both pointers - an outcome that is true only for this fabricated input and false for every real one, so the suite passes with the code wrong. The comment above the helper ("The literal shapes are copied from that script's hook block") states a coupling that does not hold. Case (E) at line 800 was added to close exactly this gap by driving real spawns, but it uses --harness claude then --harness opencode, so it never reaches the grok/kimi arm. The same file already knows the real shape: case (G) at line 398 writes token=fm.aaaabbbbcccc. Fix alongside the parser: correct the fixture to spawn's real format so (q) fails before the parser fix and passes after, and extend the real-spawn handover case to a grok or kimi first occupant.
  • ℹ️ bin/fm-collided-record-clear.sh:106 - The clear command deletes state/<id>.meta without acting on the tasktmp= line it carries, so /tmp/fm-<id>/ is orphaned permanently. bin/fm-spawn.sh records tasktmp= for every task that sets up a per-task temp root, bin/fm-teardown.sh:1226 removes it ([ -n &#34;$TASK_TMP&#34; ] &amp;&amp; rm -rf &#34;$TASK_TMP&#34;), and teardown now refuses a collided record unconditionally, so this command is the only remaining owner of that id - the same "only remaining owner" argument that justified pulling the turn-end authorization and PR-quarantine cleanup into bin/fm-task-record-lib.sh in the previous round. The path exists only in the meta being unlinked, and bin/fm-tmp-sweep.sh:529 globs &#34;$ROOT_PHYS&#34;/fm-*.??????, which does not match the bare fm-&lt;id&gt; root, so nothing can ever reclaim it. Flagged rather than fixed because the obvious repair adds an rm -rf of a meta-supplied path to a command whose stated boundary is that it removes only records - bin/fm-spawn.sh's own comment calls that teardown line "an unguarded rm -rf" - so whether to carry it here, and with what absolute-path guard, is the author's call.
  • ℹ️ bin/fm-task-record-lib.sh:24 - The new shared lib states an "Ordering contract for every caller: run fm_task_record_remove_pr_poll_artifacts FIRST, so its refusal preserves task state with nothing else already removed, then the turn-end authorizations, and only then unlink state/<id>.*" - but no function by that name exists (the function is remove_pr_poll_artifacts), and the lib's primary caller does the opposite order: bin/fm-teardown.sh:1221-1222 run remove_grok_turnend_auth / remove_kimi_turnend_auth and only then line 1227 runs remove_pr_poll_artifacts. Teardown is safe today only because of a separate early bin/fm-teardown.sh:1048 validate_pr_poll_cleanup &#34;$STATE&#34; &#34;$ID&#34; || exit 1, which the contract does not mention; if that early call were ever moved or dropped, a PR-artifact refusal would land with the grok/kimi authorization files already unlinked, which is the exact stranding the lib was extracted to prevent. bin/fm-collided-record-clear.sh:100-102 does follow the stated order. Fix: either reorder teardown's three lines to match, or restate the contract to name the real function and to record that teardown satisfies it via the early validate.

🔧 Fix: make the grok and kimi turn-end sweep actually fire
2 issues (1 warning, 1 info) still open:

  • ⚠️ bin/fm-collided-record-clear.sh:122 - The tasktmp shape refusal is raised after the destructive cleanup, so it breaks the "refusal preserves the whole record" promise it inherits. Order at lines 119-122: remove_pr_poll_artifacts, remove_grok_turnend_auth, remove_kimi_turnend_auth, then remove_task_tmp_root. remove_task_tmp_root (bin/fm-task-record-lib.sh:46-65) returns 1 for a tasktmp= that is not absolute, whose basename is not fm-<id>, or that is not a plain directory - and by then remove_pr_poll_artifacts has already run fm_pr_poll_retirement_recover_one, unlinked <id>.check.sh / .pr-poll / .pr-poll-registration / .pr-poll-retirement / .check-trust, and swept $STATE/.pr-check-quarantine/<id>.*, and both harness turn-end authorization files under ~/.grok/hooks/fm-turn-end.d and ~/.kimi-code/fm-turn-end.d are gone. The command then prints "leaving it in place" and exits 1 with state/<id>.meta still present, so the operator reads it as "nothing was touched". This contradicts two statements the same commit wrote: bin/fm-task-record-lib.sh:28 ("run remove_pr_poll_artifacts FIRST, because it is the only one of these that can REFUSE" - remove_task_tmp_root, added in that commit, also refuses) and this file's own header line 27 ("the refusal is raised before anything at all is removed"). Concrete sequence: a hand-reconstructed collided meta - the exact population this tool exists for, per the intent - carries tasktmp=/tmp/fm-<a-different-id> plus a grok token record and a quarantined PR artifact; the clear run destroys the authorization file and the quarantine entry, refuses, and leaves the record. The population is live: the intent states the captain will run this against three real at-risk homes as soon as it lands. Test case (N) at tests/fm-leased-home.test.sh:576 cannot catch it because its fixture plants no PR artifacts and no token records, so it asserts only that the meta survived. Fix: split the shape validation out of remove_task_tmp_root (mirroring the validate_pr_poll_cleanup / remove_pr_poll_artifacts split already in that lib) and run it up front beside the inside-the-home check at lines 102-111, leaving the actual rm -rf at line 122; and correct the contract text at bin/fm-task-record-lib.sh:28 to name both refusing steps.
  • ℹ️ bin/fm-teardown.sh:1230 - The hardened temp-root removal added this round is applied only to the rarely-used clear command, not to the path every task takes. bin/fm-teardown.sh:1230 still runs [ -n &#34;$TASK_TMP&#34; ] &amp;&amp; rm -rf &#34;$TASK_TMP&#34; on the raw meta-supplied value, with no absolute-path, no fm-<id>-basename, and no plain-directory check; remove_task_tmp_root has no caller in teardown at all. That leaves two statements inaccurate: bin/fm-task-record-lib.sh:21 says "bin/fm-teardown.sh is the original owner of these predicates and still their only ordinary-lifecycle caller", which is false for the temp root (teardown is not a caller and never was), and bin/fm-spawn.sh:1397 still describes that teardown line as "an unguarded rm -rf", which remains true. The hazard itself is pre-existing and not a regression - teardown now refuses a foreign-home record outright, so the collided class routes to the clear command - but a hand-edited meta carrying tasktmp=<any absolute path> still reaches teardown's unguarded rm -rf, and hand-edited metas are the root cause this whole change addresses. Flagged rather than auto-fixed because wiring remove_task_tmp_root into teardown makes a previously-silent teardown able to REFUSE, which is a user-visible behavior decision; the alternative is to correct the lib header so it stops claiming a caller it does not have.

🔧 Fix: refuse a bad tasktmp before any record cleanup runs
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 info
  • ℹ️ tests/fm-secondmate-safety.test.sh:1198 - Pre-existing, unrelated test failure: tests/fm-secondmate-safety.test.sh fails at not ok - charter scaffold failed for symlink leaf seed test. I reproduced the identical failure at the base commit 8796b71 (with the change's bin/ replaced by the baseline bin/), so it is not caused by this change. Note it differs from the pre-existing failure the author reported ('seed did not explain uninitialized existing no-mistakes clone refusal'), so this host trips a different case in the same suite; either way it fails identically before and after.
  • bin/fm-test-run.sh tests/fm-leased-home.test.sh — 39 cases, all pass (teardown guard, --force non-bypass, child sweep, collided-record clear incl. tasktmp/PR-artifact refusals, spawn pre- and post-allocation gates, fm-send endpoint ambiguity, turn-end sweep, audit classification)
  • Red-before-green: git checkout 8796b713 -- bin/ &amp;&amp; bin/fm-test-run.sh tests/fm-leased-home.test.shnot ok - marker-home teardown: expected exit 1, got 0 (worktree restored to c994f646 afterwards, git status clean)
  • Manual end-to-end incident reproduction against the real scripts in a scratch pool: /tmp/no-mistakes-evidence/01KZZD5M61DBTKNMKX95J6FA0B/incident-repro.sh fixed — runs fm-leased-home-audit.sh, fm-teardown.sh task-old --force, fm-spawn.sh scout-task, fm-collided-record-clear.sh task-old, then re-runs the audit
  • Same scenario against the base commit: git checkout 8796b713 -- bin/ &amp;&amp; incident-repro.sh baseline — shows treehouse return --force &lt;dictate&#39;s home&gt; executing and the home's branch detached
  • Manual reproduction of the two secondary reuse collisions on both commits: reuse-collisions-repro.shfm-send.sh dictate &#39;status please&#39; with two records naming sess:fm-shared, plus two real fm-spawn.sh runs into the same pool slot with different harnesses
  • bin/fm-test-run.sh tests/fm-teardown.test.sh tests/fm-teardown-endpoint-safety.test.sh tests/fm-send-strict.test.sh tests/fm-send-settle.test.sh tests/fm-send-secondmate-marker.test.sh tests/fm-spawn-batch.test.sh tests/fm-spawn-worktree-settle.test.sh tests/fm-spawn-dispatch-profile.test.sh tests/fm-secondmate-safety.test.sh tests/fm-secondmate-lifecycle-e2e.test.sh — 9 pass, 1 pre-existing failure
  • Baseline check of that failure: git checkout 8796b713 -- bin/ &amp;&amp; bin/fm-test-run.sh tests/fm-secondmate-safety.test.sh — identical failure
⚠️ **Document** - 1 info
  • ℹ️ docs/scripts.md:8 - docs/scripts.md claims to be the bin/ toolbelt inventory, but ten scripts predating this change are still absent from it (fm-lint.sh, fm-doc-audience-check.sh, fm-cd-pretool-check.sh, fm-cd-command-policy.mjs, fm-herdr-session-cleanup.sh, fm-install-shellcheck.sh, fm-push-transition-lib.sh, fm-transition-lib.sh, fm-startup-memory-budget.sh, fm-startup-memory-budget-lib.sh). I added rows only for the entrypoints and libs this change introduced; backfilling the pre-existing gaps (or adding a drift check that generates the table from bin/) is a separate consolidation and is left as a follow-up.
🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: make the tests' GROK_HOME use visible to shellcheck
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

Bchue and others added 10 commits August 14, 2026 01:50
A secondmate home and a disposable task worktree are allocated from the same
treehouse pool; only the home's durable lease separates them. Verified against
treehouse v2.1.0: plain `treehouse get` correctly skips a leased worktree, and
`prune` correctly skips it too - but `treehouse return` releases a lease for any
caller that passes no lease precondition, and firstmate passed none.

So one teardown aimed at a stale `worktree=` that had since been leased as a
home released that lease, killed the secondmate's processes, and dropped the
home into the free pool, after which an ordinary acquisition was entitled to
hand it out. No treehouse fault is involved; the gap is firstmate releasing a
lease it does not own.

bin/fm-leased-home-lib.sh is the guard. Ownership is decided from the home's own
.fm-secondmate-home marker and data/secondmates.md rather than from lease state,
so a home whose lease is ALREADY lost stays protected:

  - teardown refuses to return any home the task does not own, and --force does
    not bypass it: --force authorizes discarding THIS task's work, never another
    agent's home. Only the matching secondmate id may retire its own home.
  - spawn refuses to acquire from a pool holding an unleased registered home,
    and refuses to launch into a home it was handed anyway.
  - bin/fm-leased-home-audit.sh reports homes that are unleased, leased to the
    wrong id, untracked by their pool, or already occupied by a task record. It
    reads each home's own pool records, not just the live listing, because
    `treehouse status` only ever reports the one pool the backing repo resolves
    to today.

Two further instances of the same identifier-reuse shape, found from the same
incident and covered here:

  - fm-send refuses when two live task records name the same backend endpoint.
    A herdr pane id or tmux window index is reissued once its occupant exits, so
    a recorded endpoint can come to name another task's live session while still
    passing an existence check - which delivered one secondmate's steer into
    another's session. The refusal is decided from firstmate's own records
    before any backend is consulted, so it holds for every backend.
  - spawn clears turn-end hooks left by a worktree's previous occupant
    (bin/fm-turnend-artifact-lib.sh). Writing this task's own hook overwrites
    only the artifact its harness uses, and a secondmate spawn writes none at
    all, so a leftover Stop hook kept firing wakes for a task id that no longer
    existed. Files carrying no firstmate turn-end signature are left alone.

tests/fm-leased-home.test.sh covers all three layers; verified the teardown
cases fail without the guard.
fm-teardown.sh now sources fm-leased-home-lib.sh and fm-task-record-lib.sh,
but two suites assemble a partial bin/ from a named list of siblings and had
neither, so the real teardown they invoke aborted under set -eu with "No such
file or directory" - fm-backend's old-vs-new conformance case (which pins
BASE_REF to HEAD, so its "old" teardown is this one) and both fm-gotmp fake
roots. Add the missing siblings; fm-turnend-artifact-lib.sh joins the same
list because fm-spawn.sh sources it from that shim too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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