fix(bin): stop a reused pool slot from becoming another agent's home - #24
Open
brchue-ux wants to merge 10 commits into
Open
fix(bin): stop a reused pool slot from becoming another agent's home#24brchue-ux wants to merge 10 commits into
brchue-ux wants to merge 10 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
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
bin/fm-leased-home-lib.sh, a record-based ownership guard that identifies a secondmate home from its own.fm-secondmate-homemarker anddata/secondmates.mdrather than from treehouse lease state, and wires it into both lifecycle ends:fm-teardown.shrefuses a task whose recordedworktree=names a home it does not own — before the stale-lock cleanup, branch deletion, and turn-end hook removal, and again inside everytreehouse return— with--forcedeliberately not bypassing it, whilefm-spawn.shrefuses to allocate from a pool holding an unprotected home (warn-only when pool state is unreadable) and re-asserts post-allocation invalidate_spawn_worktree. Addsbin/fm-leased-home-audit.shto report unleased, mismatched, untracked, or already-collided homes (falling back to each home's owntreehouse-state.jsonso second pools of the same repo are visible), andbin/fm-collided-record-clear.shas the only supported way to retire an already-collided record without touching the home.tasktmp=cleanup out offm-teardown.shintobin/fm-task-record-lib.shso the new clear command reuses the same refusing protocol; addsbin/fm-turnend-artifact-lib.shand 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.jsonalone).fm-send.shrefuse, 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; addstests/fm-leased-home.test.sh(17 cases), routes the new paths throughfm-test-run.shfamily selection, and documents the guard, audit, and clear commands indocs/architecture.md,docs/configuration.md,docs/scripts.md, and thesecondmate-provisioningskill.fm-home-seed.shgains only a comment recording why its rollback path is deliberately unguarded.Risk Assessment
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 --forcecompletes and issuestreehouse return --forceon 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-onlystatus. The same transcript shows the pool-side spawn refused before any window or task record exists,fm-collided-record-clear.shclearing 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 atfirst-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-existingfm-secondmate-safetycharter-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)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 releasedEvidence: 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-endedEvidence: 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-endedEvidence: 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=6996Pipeline
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 rungit checkout --detach+git branch -Dwhen the target is on a branch, and line 1173-1175 unconditionallyrm -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 sobranch -Dis 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 withsed -n 's/.../\1\t\2/p'.\tin the replacement is a GNU sed extension; BSD/macOS sed substitutes a literalt, so each line comes back as "<id>t<path>" in one field. Every consumer then drops it (IFS=$'\t' read -r entry_id entry_homeleaves entry_home empty and the loopscontinue), 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 '\t')) 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 thecase $BACKENDblock 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') asin-useand 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 thecase $BACKENDblock makes the refusal side-effect-free.bin/fm-leased-home-lib.sh:55- fm_leased_home_marker_id doesIFS= read -r id < "$marker" 2>/dev/null || return 1. bash'sreadreturns 1 when it hits EOF without the delimiter, so a .fm-secondmate-home written without a trailing newline (a hand-repaired marker,printf '%s',echo -n) makes the predicate return failure even thoughidwas 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) usescatand tolerates it, so this is a new, stricter reader in the safety path. Fix:IFS= read -r id < "$marker" 2>/dev/null || [ -n "$id" ] || 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 topr-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.shselection under bin/fm-guard.sh - so giving bin/fm-teardown.sh its own arm with__script__:fm-leased-home.test.shcloses 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 faketreehouseplus 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 isrm -rf. Concrete sequence:cleanup_firstmate_home_children(reached by a kind=secondmate teardown, line 1053 nested or line 1121 under --force) iterates the home'sstate/*.meta; for an ordinary child whose recordedworktree=names a DIFFERENT secondmate's home (the exact stale-record class this change exists to close - the sweep found three such homes), line 1068 callsteardown_treehouse_return "$child_wt" "$child_proj" "child worktree"with no owner id.fm_leased_home_guardprints REFUSED and returns 1. The caller only propagatesTEARDOWN_TREEHOUSE_LOCK_REFUSED(=2, bin/fm-teardown.sh:561), so rc=1 falls tosafe_rm_rf_child_worktreeat line 1075, which passesvalidate_child_worktree_for_removalwhenevergit -C "$child_proj" worktree listnames 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-1066rm -f.claude/settings.local.json,.opencode/plugins/fm-turn-end.js,.fm-grok-turnendand.fm-kimi-turnendin 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 therm -fat 1065 in the same way it was hoisted in the ordinary-task path.bin/fm-spawn.sh:1034-assert_pool_has_no_unprotected_home "$PROJ"passes the raw project argument.$PROJis${POS[1]}verbatim (line 460) and the documented batch/spawn form isprojects/<name>(see the usage example at bin/fm-spawn.sh:112), which only becomes a real directory afterresolve_project_dir_argmaps it to$PROJECTS/<name>.fm_leased_home_pool_statusdoes( cd "$project" && treehouse status --json ), so for aprojects/fooargument 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, printingwarning: could not read the treehouse pool for projects/fooon 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_ABSis already resolved at line 870, long before this call site. Fix: pass$PROJ_ABS. The new tests miss this becauserun_spawnalways passes an absolute path.bin/fm-leased-home-lib.sh:59-IFS= read -r id < "$marker" 2>/dev/null || [ -n "$id" ] || return 1references$idon a path where it can still be unset.local dir=$1 marker iddeclaresidlocal 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 -readnever runs and never assigns, so[ -n "$id" ]triggersset -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 isid=$(fm_leased_home_marker_id "$abs")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: declareid=''in thelocalline.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 livetreehouse status --jsonlisting. That is the only case that pins the[ "$status" != leased ]comparison infm_leased_home_unprotected, and it matters because real treehouse v2.1.0 reportsstatus:"leased"(not"in-use") for a leased slot even when processes are running: if that comparison ever drifted,assert_pool_has_no_unprotected_homewould 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"status":"leased","lease_holder":"dictate"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 omitsleasedentirely 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 "$path" ] || continuedrops 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 // "") == "" then "-" 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 <herdr-session>:<pane-id> "..."orfm-send.sh <tmux-session>:fm-<id> "..."- 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 1045continues, 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 isjq -er ... "$state", 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.worktreeskey 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 ([ "$record_rc" -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 ownreturn 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 plainrm -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 thenrm -f "${GROK_HOME:-$HOME/.grok}/hooks/fm-turn-end.d/$token"(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 barerm -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 writesprintf 'token=%s\n' "${auth_file##*/}"into $WT/.fm-grok-turnend and $WT/.fm-kimi-turnend (bin/fm-spawn.sh:1521 and 1527), where auth_file ismktemp "$GROK_AUTH_DIR/fm.XXXXXXXXXXXX"- so the pointer content istoken=fm.aaaabbbbcccc. The parser stripstoken=, 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 "$owner" ] || continueskips the file, and it survives. Verified directly against the shipped lib: a file containingtoken=fm.aaaabbbbccccyields 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 writestoken=$owner.grok-turnend-token/token=$owner.kimi-turnend-tokeninto 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 writestoken=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 thetasktmp=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 "$TASK_TMP" ] && rm -rf "$TASK_TMP"), 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"$ROOT_PHYS"/fm-*.??????, which does not match the barefm-<id>root, so nothing can ever reclaim it. Flagged rather than fixed because the obvious repair adds anrm -rfof 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:1048validate_pr_poll_cleanup "$STATE" "$ID" || 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 "$TASK_TMP" ] && rm -rf "$TASK_TMP"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.
tests/fm-secondmate-safety.test.sh:1198- Pre-existing, unrelated test failure:tests/fm-secondmate-safety.test.shfails atnot 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/ && bin/fm-test-run.sh tests/fm-leased-home.test.sh→not ok - marker-home teardown: expected exit 1, got 0(worktree restored to c994f646 afterwards,git statusclean)Manual end-to-end incident reproduction against the real scripts in a scratch pool:/tmp/no-mistakes-evidence/01KZZD5M61DBTKNMKX95J6FA0B/incident-repro.sh fixed— runsfm-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 auditSame scenario against the base commit:git checkout 8796b713 -- bin/ && incident-repro.sh baseline— showstreehouse return --force <dictate's home>executing and the home's branch detachedManual reproduction of the two secondary reuse collisions on both commits:reuse-collisions-repro.sh—fm-send.sh dictate 'status please'with two records namingsess:fm-shared, plus two realfm-spawn.shruns into the same pool slot with different harnessesbin/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 failureBaseline check of that failure:git checkout 8796b713 -- bin/ && bin/fm-test-run.sh tests/fm-secondmate-safety.test.sh— identical failuredocs/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 ✅
🔧 Fix: make the tests' GROK_HOME use visible to shellcheck
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.