From 09d8cfa5a56c68394b88d2fe6ca71b1b07d4ee59 Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 13 Sep 2026 14:31:44 +0200
Subject: [PATCH 01/10] fix(treehouse): refuse pool-slot reissuance while a
stale record still claims it
fm-spawn's crewmate path now acquires the pool slot through Treehouse's own
durable get --lease --lease-holder rather than the interactive pane's plain
get, and binds the spawn-side owner claim to that lease. Before any get,
fm-spawn refuses when a still-recorded state/*.meta anywhere reachable
(including secondmate homes) names that slot without a durable lease, closing
the reissuance window a landed-but-not-torn-down task record used to leave
open. fm-teardown re-verifies the recorded lease at every destructive
boundary and now refuses outright (never with --force) on a claim naming
another task or a lease that no longer matches, rather than warning and
finishing only its own cleanup, per the requirement to refuse otherwise.
fm_treehouse_require_reserved_records only enforces the lease requirement for
paths structurally registered in a treehouse-state.json, so an ordinary
worktree recorded incidentally against the same project is never mistaken
for a pool slot.
---
bin/fm-spawn.sh | 31 +--
bin/fm-teardown.sh | 239 +++++++---------------
bin/fm-wake-lib.sh | 222 ++++++++++++++++----
docs/architecture.md | 3 +-
tests/fixtures.sh | 31 ++-
tests/fm-spawn-pool-base-freshen.test.sh | 44 +++-
tests/fm-tangle-guard.test.sh | 4 +-
tests/fm-teardown-endpoint-safety.test.sh | 21 +-
8 files changed, 355 insertions(+), 240 deletions(-)
diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh
index b1b8608531d..9d4ca252468 100755
--- a/bin/fm-spawn.sh
+++ b/bin/fm-spawn.sh
@@ -131,14 +131,9 @@
# root Firstmate home's state directory before slot allocation and holds it through
# task metadata publication. Teardown holds that same lock while proving and
# returning a slot, so allocation cannot reuse a slot before its owner record
-# is published. Under that same lock it writes the slot's owner claim, which is
-# what lets teardown leave a slot reassigned since untouched; bin/fm-wake-lib.sh
-# owns the claim and bin/fm-teardown.sh owns what it protects. A slot that
-# cannot be claimed refuses the spawn rather than launching a worker whose slot
-# could later be released out from under its successor. A spawn that aborts
-# while it still holds the allocation lock drops its own claim; an abort after
-# metadata publication has released that lock leaves the claim in place, and
-# the next spawn's claim replaces it.
+# is published. Slot leasing, legacy-record preflight, and owner binding are
+# owned by bin/fm-wake-lib.sh. An aborted spawn leaves its native lease reserved
+# for reconciliation; it never returns a copy whose work was not inspected.
# The local root is whatever bin/fm-wake-lib.sh's
# fm_firstmate_root_home resolves, so a home seeded from another machine anchors
# that lock itself rather than failing to resolve one;
@@ -3818,7 +3813,11 @@ if [ "$RELAUNCH" -eq 1 ]; then
fi
[ "$KIND" = secondmate ] || validate_spawn_worktree "relaunch" "$T"
elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
- spawn_send_text_line "$WT_TARGET" 'treehouse get'
+ fm_treehouse_require_reserved_records "$PROJ_ABS" || exit 1
+ SPAWN_LEASE_HOLDER=$(fm_treehouse_lease_holder "$ID" "$FM_HOME") || exit 1
+ # Keep acquisition in the task shell so Treehouse uses the same pool config
+ # and environment as before. A durable lease survives this shell's exit.
+ spawn_send_text_line "$WT_TARGET" "fm_slot=\$(treehouse get --lease --lease-holder $(shell_quote "$SPAWN_LEASE_HOLDER")) && [ -n \"\$fm_slot\" ] && cd -- \"\$fm_slot\""
# Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree.
# Target the stable window id, not the name: if the name is ever lost (e.g. an
@@ -3879,18 +3878,8 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
validate_spawn_worktree "treehouse get" "$T"
- # Claim the pool slot for this task. The interactive `treehouse get` sent to
- # the pane above records only a process lease (Treehouse's durable
- # `get --lease --lease-holder`, which bin/fm-home-seed.sh uses for secondmate
- # homes, is not this path), so Treehouse cannot say which task a slot belongs
- # to once that task's worker exits - and that is exactly when the slot is
- # handed on and this task's worktree= line goes stale. The claim is what lets
- # bin/fm-teardown.sh leave a slot that has since been reassigned untouched, so
- # a slot that cannot be claimed is refused here, at the cheapest point, rather
- # than launching a worker whose slot teardown could later release out from
- # under its successor.
- # Written under the Treehouse project lock held from before slot allocation
- # through metadata publication, so no other spawn or return sees a half-claim.
+ # Bind the native lease to this task before refreshing or launching. The
+ # shared project lock covers allocation, claim, and metadata publication.
if fm_treehouse_pool_slot "$PROJ_ABS" "$WT"; then
if ! fm_treehouse_slot_owner_claim "$WT" "$ID" "$FM_HOME"; then
echo "error: could not claim Treehouse pool slot $WT for task $ID; refusing to launch a worker whose slot cannot later be proved to be its own; inspect window $T" >&2
diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh
index ec792392b98..ebe124ce845 100755
--- a/bin/fm-teardown.sh
+++ b/bin/fm-teardown.sh
@@ -95,25 +95,10 @@
# reads the slot's own owner claim, written by bin/fm-spawn.sh at the moment the
# slot is taken and dropped here once it is genuinely returned; bin/fm-wake-lib.sh
# owns the claim, its location, and its states. A claim naming another task is
-# proof of reassignment: the slot is no longer this task's, so teardown warns,
-# names the claimant, and then finishes only this task's own cleanup - endpoint,
-# status, records, checks, backlog - while every step that would read or touch
-# that slot is skipped: no process kill under it, no dirty or landed-work
-# inspection of it, no branch or hook removal in it, no Treehouse return, and
-# never the other task's claim. Skipping the inspection discards nothing of this
-# task's: whatever unlanded work it had in that slot was already destroyed when
-# the pool handed the slot on. Refusing instead would strand the record, because
-# bin/fm-backend.sh's endpoint validation refuses an empty or missing worktree=
-# unconditionally, so there is no line an operator could clear to get past it.
-# A claim that cannot be read proves nothing either way and refuses; inspect or
-# repair the claim file at the printed path and re-run - never remove it, since
-# an absent claim proceeds and would return a slot that may be another task's. An
-# absent claim - a slot taken before claims existed, or already returned - keeps
-# exactly the record-scan protection it had before, because refusing it would
-# strand every task in flight across that change on no evidence at all.
-# Why Treehouse's own state cannot answer this for crewmate slots, and why the
-# claim file sits on top of it, is owned by bin/fm-wake-lib.sh's slot-owner
-# claim comment.
+# proof of reassignment and refuses before any task cleanup. An unreadable claim
+# also refuses. An absent legacy claim keeps the record-scan protection; a new
+# claim additionally binds the native lease identity. No refusal is relaxed by
+# --force. Reconcile the ownership records before retrying.
# The recorded endpoint's exact task identity and the record's spawn incarnation
# are validated separately
# before cleanup. Its current working directory is only incidental process
@@ -1635,13 +1620,26 @@ cleanup_stale_lock_for_safety_check() {
# Return a worktree/home via `treehouse return --force`, tolerating a transient or
# stale git index.lock left by a killed crew process. See the script header.
+teardown_treehouse_return_once() { #
+ local dir=$1 project=$2 task_id=$3 home=$4
+ local -a args
+ args=(return --force "$dir")
+ if fm_treehouse_pool_slot "$project" "$dir"; then
+ require_owned_worktree_slot_record "$task_id" "$dir" "$home" || return 1
+ if [ -n "$FM_TREEHOUSE_SLOT_OWNER_LEASE" ]; then
+ args+=(--if-lease-id "$FM_TREEHOUSE_SLOT_OWNER_LEASE")
+ fi
+ fi
+ (cd "$project" && treehouse "${args[@]}")
+}
+
teardown_treehouse_return() {
- local dir=$1 cd_dir=$2 label=$3 post_cleanup_check=${4:-}
+ local dir=$1 cd_dir=$2 label=$3 post_cleanup_check=${4:-} task_id=${5:-$ID} home=${6:-$FM_HOME}
local out lock attempt=0 max_retries lock_desc
# Capture stdout+stderr so non-lock failures stay visible and lock failures can
# be matched by signature even when the lock file is already gone mid-check.
- if out=$( ( cd "$cd_dir" && treehouse return --force "$dir" ) 2>&1 ); then
+ if out=$( teardown_treehouse_return_once "$dir" "$cd_dir" "$task_id" "$home" 2>&1 ); then
[ -n "$out" ] && printf '%s\n' "$out"
return 0
fi
@@ -1666,7 +1664,7 @@ teardown_treehouse_return() {
echo "teardown: $label return failed with transient git lock ($lock_desc); waiting ${TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS}s and retrying ($attempt/${max_retries})" >&2
sleep "$TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS"
- if out=$( ( cd "$cd_dir" && treehouse return --force "$dir" ) 2>&1 ); then
+ if out=$( teardown_treehouse_return_once "$dir" "$cd_dir" "$task_id" "$home" 2>&1 ); then
[ -n "$out" ] && printf '%s\n' "$out"
echo "teardown: $label return succeeded on retry; lock cleared on its own" >&2
return 0
@@ -1693,7 +1691,7 @@ teardown_treehouse_return() {
return 1
fi
fi
- if out=$( ( cd "$cd_dir" && treehouse return --force "$dir" ) 2>&1 ); then
+ if out=$( teardown_treehouse_return_once "$dir" "$cd_dir" "$task_id" "$home" 2>&1 ); then
[ -n "$out" ] && printf '%s\n' "$out"
echo "teardown: $label return succeeded after stale-lock cleanup" >&2
return 0
@@ -2157,57 +2155,12 @@ teardown_live_slot_path() {
canonical_existing_dir "$WT"
}
-collect_local_firstmate_states() {
- local record_state=$1 root home reg line child known existing i=0
- local -a homes
- TREEHOUSE_OWNER_STATES=("$record_state")
- root=$(fm_firstmate_root_home "$FM_HOME") || {
- echo "REFUSED: cannot resolve the root Firstmate home; nothing was changed" >&2
- return 1
- }
- homes=("$root")
- while [ "$i" -lt "${#homes[@]}" ]; do
- home=${homes[$i]}
- i=$((i + 1))
- known=0
- for existing in "${TREEHOUSE_OWNER_STATES[@]}"; do
- [ "$existing" != "$home/state" ] || known=1
- done
- [ "$known" = 1 ] || TREEHOUSE_OWNER_STATES+=("$home/state")
- reg="$home/data/secondmates.md"
- [ ! -e "$reg" ] && [ ! -L "$reg" ] && continue
- [ -f "$reg" ] && [ ! -L "$reg" ] || {
- echo "REFUSED: local Firstmate registry is unsafe at $reg; nothing was changed" >&2
- return 1
- }
- while IFS= read -r line || [ -n "$line" ]; do
- case "$line" in
- "- "*)
- secondmate_registry_parse_line "$line" || {
- echo "REFUSED: malformed local Firstmate registry entry in $reg; nothing was changed" >&2
- return 1
- }
- [ "$SECONDMATE_REGISTRY_REMOTE" -eq 0 ] || continue
- child=$(canonical_existing_dir "$SECONDMATE_REGISTRY_HOME") || {
- echo "REFUSED: registered local Firstmate home is unavailable: $SECONDMATE_REGISTRY_HOME; nothing was changed" >&2
- return 1
- }
- known=0
- for existing in "${homes[@]}"; do
- [ "$existing" != "$child" ] || known=1
- done
- [ "$known" = 1 ] || homes+=("$child")
- ;;
- esac
- done < "$reg"
- done
-}
require_exclusive_worktree_slot_record() {
local record_meta=$1 record_id=$2 record_state=$3 worktree=$4
local slot state_dir other other_id field other_path other_slot
slot=$(canonical_existing_dir "$worktree") || return 0
- collect_local_firstmate_states "$record_state" || return 1
+ fm_treehouse_collect_states "$record_state" || return 1
for state_dir in "${TREEHOUSE_OWNER_STATES[@]}"; do
for other in "$state_dir"/*.meta; do
[ -f "$other" ] && [ ! -L "$other" ] || continue
@@ -2233,70 +2186,38 @@ require_exclusive_task_worktree_slot() {
require_exclusive_worktree_slot_record "$META" "$ID" "$STATE" "$slot"
}
-# Positive slot ownership, read from the claim the task that took the slot wrote
-# into the slot itself (bin/fm-wake-lib.sh owns the claim and its states).
-#
-# The record scan above proves that no OTHER task record names this slot. It
-# cannot prove that THIS record is not the stale one, because the task that took
-# the slot next may leave no record this scan can reach: its own worker may have
-# exited and its record been cleaned up, or it may belong to a home this machine
-# does not register. The claim closes that gap from the other side - it names the
-# task that actually took the slot, and it is written under the same project lock
-# that allocates it - so a claim naming another task is proof the slot was
-# reassigned after this record was written.
-#
-# A claim naming another task does not refuse: it means the slot is no longer
-# this task's, so the record's own cleanup proceeds and every slot step is
-# skipped (see the script header for why refusing would strand the record and
-# why skipping discards nothing). Returns TEARDOWN_SLOT_REASSIGNED_RC for that
-# state so each caller gates its slot steps on one determination; the claimant
-# stays in FM_TREEHOUSE_SLOT_OWNER_ID and FM_TREEHOUSE_SLOT_OWNER_HOME.
-#
-# An absent claim proceeds as the slot's owner: a slot taken before claims
-# existed, or already returned to the pool, carries none, and refusing those
-# would strand every task in flight across the change for no evidence at all.
-# Those keep exactly the record-scan protection they had before.
-TEARDOWN_SLOT_REASSIGNED_RC=3
-require_owned_worktree_slot_record() { #
- local record_id=$1 worktree=$2 marker
- fm_treehouse_slot_owner_state "$worktree" "$record_id"
+# Slot ownership is checked before any cleanup and again at each destructive
+# boundary. A task id is only unique within its home. New claims also bind the
+# native Treehouse lease; a matching marker cannot authorize a replaced lease.
+require_owned_worktree_slot_record() { # [home]
+ local record_id=$1 worktree=$2 home=${3:-$FM_HOME} marker lease holder
+ fm_treehouse_slot_owner_state "$worktree" "$record_id" "$home"
case "$FM_TREEHOUSE_SLOT_OWNER" in
- mine|absent) return 0 ;;
+ absent) return 0 ;;
+ mine)
+ [ -n "$FM_TREEHOUSE_SLOT_OWNER_LEASE" ] || return 0
+ holder=$(fm_treehouse_lease_holder "$record_id" "$home") || return 1
+ lease=$(fm_treehouse_slot_lease "$worktree") || lease=
+ if [ "$lease" = "$FM_TREEHOUSE_SLOT_OWNER_LEASE"$'\t'"$holder" ]; then
+ return 0
+ fi
+ echo "REFUSED: task $record_id's recorded worktree $worktree no longer holds its Treehouse lease $FM_TREEHOUSE_SLOT_OWNER_LEASE (current lease: ${lease:-absent or unreadable}); nothing was changed - not even with --force." >&2
+ return 1
+ ;;
other)
- echo "warning: task $record_id's recorded worktree $worktree was reassigned to task $FM_TREEHOUSE_SLOT_OWNER_ID${FM_TREEHOUSE_SLOT_OWNER_HOME:+ (home $FM_TREEHOUSE_SLOT_OWNER_HOME)}, which claimed that pool slot after this record was written; that slot is no longer $record_id's, so its processes, copy, and claim are left untouched and only $record_id's own cleanup runs." >&2
- return "$TEARDOWN_SLOT_REASSIGNED_RC"
+ echo "REFUSED: task $record_id's recorded worktree $worktree was reassigned to task $FM_TREEHOUSE_SLOT_OWNER_ID (home $FM_TREEHOUSE_SLOT_OWNER_HOME); preserving the slot and task records - not even --force authorizes touching another task's work." >&2
+ return 1
;;
esac
marker=$(fm_treehouse_slot_owner_marker "$worktree" 2>/dev/null) || marker="beside $worktree"
- echo "REFUSED: task $record_id's recorded worktree $worktree carries a slot-owner claim that cannot be read, so the slot cannot be proved to still be this task's; nothing was changed - not even with --force." >&2
- echo "Inspect or repair the claim file at $marker (task= and home= lines), then re-run teardown." >&2
+ echo "REFUSED: task $record_id's recorded worktree $worktree carries an unreadable slot-owner claim at $marker; reconcile ownership before retrying." >&2
return 1
}
-# The one ownership determination for this task's recorded slot. Every later
-# step that would read or touch $WT consults teardown_owns_worktree, so a
-# reassigned slot is skipped consistently rather than by each step's own guess.
-TEARDOWN_SLOT_REASSIGNED=0
-TEARDOWN_SLOT_REASSIGNED_TO=
-TEARDOWN_SLOT_REASSIGNED_HOME=
require_owned_task_worktree_slot() {
- local slot rc=0
+ local slot
slot=$(teardown_live_slot_path) || return 0
- require_owned_worktree_slot_record "$ID" "$slot" || rc=$?
- case "$rc" in
- 0) return 0 ;;
- "$TEARDOWN_SLOT_REASSIGNED_RC")
- TEARDOWN_SLOT_REASSIGNED=1
- TEARDOWN_SLOT_REASSIGNED_TO=$FM_TREEHOUSE_SLOT_OWNER_ID
- TEARDOWN_SLOT_REASSIGNED_HOME=$FM_TREEHOUSE_SLOT_OWNER_HOME
- return 0
- ;;
- esac
- return 1
-}
-
-teardown_owns_worktree() {
- [ "$TEARDOWN_SLOT_REASSIGNED" != 1 ]
+ require_owned_worktree_slot_record "$ID" "$slot"
}
firstmate_home_has_treehouse_slot() {
@@ -2766,7 +2687,7 @@ preflight_descendant_task_locks() {
}
preflight_descendant_treehouse_slots() {
- local i state task_id meta kind backend target worktree project lock_path held owner_rc
+ local i state task_id meta kind backend target worktree project lock_path held
for ((i=0; i < ${#DESCENDANT_TASK_IDS[@]}; i++)); do
state=${DESCENDANT_TASK_STATES[$i]}
task_id=${DESCENDANT_TASK_IDS[$i]}
@@ -2817,12 +2738,7 @@ preflight_descendant_treehouse_slots() {
fi
fm_backend_validate_task_endpoint "$meta" "$task_id" || return 1
require_exclusive_worktree_slot_record "$meta" "$task_id" "$state" "$worktree" || return 1
- owner_rc=0
- require_owned_worktree_slot_record "$task_id" "$worktree" || owner_rc=$?
- case "$owner_rc" in
- 0|"$TEARDOWN_SLOT_REASSIGNED_RC") ;;
- *) return 1 ;;
- esac
+ require_owned_worktree_slot_record "$task_id" "$worktree" "${state%/state}" || return 1
done
}
@@ -3038,7 +2954,7 @@ endpoint_close_refusal() { #
}
cleanup_firstmate_home_children() {
- local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen child_owner_rc
+ local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen
sub_state="$home/state"
[ -d "$sub_state" ] || return 0
for child_meta in "$sub_state"/*.meta; do
@@ -3060,6 +2976,10 @@ cleanup_firstmate_home_children() {
validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1
fi
fi
+ if [ "$child_kind" != secondmate ] && [ "$child_backend" != orca ] \
+ && fm_treehouse_pool_slot "$child_proj" "$child_wt"; then
+ require_owned_worktree_slot_record "$child_id" "$child_wt" "$home" || return 1
+ fi
if [ -n "$child_t" ]; then
if [ "$child_backend" = herdr ]; then
fm_backend_herdr_parse_target "$child_t" || return 1
@@ -3097,36 +3017,25 @@ cleanup_firstmate_home_children() {
fi
fm_backend_remove_worktree "$child_backend" "$child_orca_worktree_id" || return 1
elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then
- # The same ownership determination as the parent's own slot: a child
- # slot reassigned to another task is not this child's to kill, reset,
- # or return, so only its records are cleaned up. The preflight above
- # already named the reassignment on stderr under the same lock.
- child_owner_rc=0
- if fm_treehouse_pool_slot "$child_proj" "$child_wt"; then
- require_owned_worktree_slot_record "$child_id" "$child_wt" 2>/dev/null || child_owner_rc=$?
- fi
- if [ "$child_owner_rc" -eq "$TEARDOWN_SLOT_REASSIGNED_RC" ]; then
- :
- elif [ "$child_owner_rc" -ne 0 ]; then
- require_owned_worktree_slot_record "$child_id" "$child_wt" || return 1
- else
- validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1
- rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \
- "$child_wt/.opencode/plugins/fm-busy-state.js" \
- "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend"
- if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then
- if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then
- fm_treehouse_slot_owner_release "$child_wt" "$child_id"
- else
- child_return_rc=$?
- if [ "$child_return_rc" -eq "$TEARDOWN_TREEHOUSE_LOCK_REFUSED" ]; then
- return "$child_return_rc"
- fi
- safe_rm_rf_child_worktree "$child_wt" "$child_proj"
- fi
+ validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1
+ rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \
+ "$child_wt/.opencode/plugins/fm-busy-state.js" \
+ "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend"
+ if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then
+ if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree" "" "$child_id" "$home"; then
+ fm_treehouse_slot_owner_release "$child_wt" "$child_id" "$home"
else
+ child_return_rc=$?
+ # A failed conditional return is never permission to bypass the lease
+ # check by removing the checkout directly.
+ if fm_treehouse_pool_slot "$child_proj" "$child_wt" \
+ || [ "$child_return_rc" -eq "$TEARDOWN_TREEHOUSE_LOCK_REFUSED" ]; then
+ return "$child_return_rc"
+ fi
safe_rm_rf_child_worktree "$child_wt" "$child_proj"
fi
+ else
+ safe_rm_rf_child_worktree "$child_wt" "$child_proj"
fi
fi
remove_grok_turnend_auth "$sub_state" "$child_id" || return 1
@@ -3274,7 +3183,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] &&
ORCA_PATH_MATCH_VERIFIED=1
fi
-if teardown_owns_worktree && [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then
+if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then
if validate_worktree_teardown_safety; then
:
else
@@ -3389,11 +3298,10 @@ fi
# kind=secondmate: a secondmate home's own runtime lifecycle is owned by the
# dedicated process-event and firstmate-home removal machinery further below,
# not by task-worktree cleanup.
-if [ "$KIND" != secondmate ] && teardown_owns_worktree; then
+if [ "$KIND" != secondmate ]; then
+ require_owned_task_worktree_slot || exit 1
conclude_task_no_mistakes_run "$WT"
reap_task_worktree_processes worktree "$WT" "$TASK_TMP"
-elif [ "$KIND" != secondmate ]; then
- reap_task_worktree_processes tasktmp "$TASK_TMP"
fi
# Fix 3 (see script header): sweep remote job workers abandoned by an already
@@ -3422,9 +3330,8 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
|| { endpoint_close_refusal "$ID" "$BACKEND" "$T" 0; exit 1; }
fi
fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID"
-elif [ "$KIND" != secondmate ] && ! teardown_owns_worktree; then
- :
elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then
+ require_owned_task_worktree_slot || exit 1
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
@@ -3642,9 +3549,7 @@ if [ -d "$STATE" ]; then
fi
if [ "$TEARDOWN_LEGACY_ACCEPTED" = 1 ]; then
echo "teardown $ID complete (window $T, worktree $WT, legacy record accepted without spawn_gen: endpoint $TEARDOWN_LEGACY_ENDPOINT, incarnation $TEARDOWN_META_SPAWN_GEN)"
-elif teardown_owns_worktree; then
- echo "teardown $ID complete (window $T, worktree $WT)"
else
- echo "teardown $ID complete (window $T; pool slot $WT left to task $TEARDOWN_SLOT_REASSIGNED_TO${TEARDOWN_SLOT_REASSIGNED_HOME:+ (home $TEARDOWN_SLOT_REASSIGNED_HOME)}, which it was reassigned to)"
+ echo "teardown $ID complete (window $T, worktree $WT)"
fi
backlog_refresh_reminder
diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh
index 6a7590cafe3..1db5c31358a 100755
--- a/bin/fm-wake-lib.sh
+++ b/bin/fm-wake-lib.sh
@@ -1170,7 +1170,7 @@ fm_task_set_lock_path() { #
# the walk at the current home, which is the correct answer rather than an
# error: the parent lives on another machine, so its filesystem can neither hold
# nor be observed by a lock taken here, and a remote-seeded home is itself the
-# top of the local tree that bin/fm-teardown.sh's collect_local_firstmate_states
+# top of the local tree that fm_treehouse_collect_states
# enumerates (that walk already skips remote registry entries for the same
# reason). Refusing a remote binding instead made every operation anchored here
# fail closed inside a remote secondmate home and its local descendants.
@@ -1249,39 +1249,174 @@ fm_treehouse_pool_slot() { #
[ "$project_common" = "$slot_common" ]
}
-# Slot-owner claim: which task a Treehouse pool slot currently belongs to.
-#
-# Treehouse can record ownership durably: `treehouse get --lease --lease-holder`
-# reserves a slot under a label until `treehouse return --if-lease-holder`
-# releases it, and Firstmate uses exactly that for secondmate homes
-# (bin/fm-home-seed.sh). Crewmate spawns do not take that path: they acquire
-# their slot through the interactive pane-driven `treehouse get`, whose state
-# entry is a live process lease (owner_pid plus owner_started_at, and `treehouse
-# status` reports in-use from the processes actually running under the path).
-# That answers "is anything running here", never "which task owns this", and it
-# is released by the very event that makes a task record stale - the worker
-# exiting - so a slot whose lease has lapsed reads identical whether it is still
-# this task's or has since been handed to another one. Firstmate therefore keeps
-# its own claim on top: one file naming the task that took the slot, written by
-# bin/fm-spawn.sh under the same project lock that allocates the slot and
-# released by bin/fm-teardown.sh when the slot goes back to the pool. Moving
-# crewmate spawns onto the durable lease is separate follow-up work.
-#
-# The claim lives at //.fm-slot-owner - a sibling of the repo
-# checkout rather than a file inside it - so claiming a slot can never dirty the
-# copy teardown's landed-work checks inspect, and a returned slot carries no
-# untracked leftover from it.
+# Enumerate discoverable local homes for slot ownership, including the invoking
+# home's overrides, its parent chain, registered descendants, and secondmate
+# metadata not yet reflected in a registry. Remote routes are another filesystem.
+# Missing state directories have no records; unreadable or unsafe records fail
+# closed instead of turning an incomplete scan into allocation authority.
+fm_treehouse_collect_states() { #
+ local record_state=$1 root home state reg line child existing known i=0 meta
+ local -a homes registries
+ # shellcheck source=bin/fm-secondmate-registry-lib.sh
+ . "$FM_WAKE_LIB_DIR/fm-secondmate-registry-lib.sh"
+ root=$(fm_firstmate_root_home "$FM_HOME") || {
+ echo "REFUSED: cannot resolve the root Firstmate home; nothing was changed" >&2
+ return 1
+ }
+ homes=("$root" "$FM_HOME")
+ TREEHOUSE_OWNER_STATES=()
+ while [ "$i" -lt "${#homes[@]}" ]; do
+ home=$(CDPATH='' cd -- "${homes[$i]}" 2>/dev/null && pwd -P) || {
+ echo "REFUSED: registered local Firstmate home is unavailable: ${homes[$i]}" >&2
+ return 1
+ }
+ i=$((i + 1))
+ state="$home/state"
+ [ "$home" != "$(CDPATH='' cd -- "$FM_HOME" && pwd -P)" ] || state=$record_state
+ [ ! -e "$state" ] && [ ! -L "$state" ] || {
+ [ -d "$state" ] && [ -r "$state" ] && [ -x "$state" ] || {
+ echo "REFUSED: local Firstmate state is unreadable at $state" >&2
+ return 1
+ }
+ state=$(CDPATH='' cd -- "$state" && pwd -P) || return 1
+ }
+ known=0
+ for existing in "${TREEHOUSE_OWNER_STATES[@]+"${TREEHOUSE_OWNER_STATES[@]}"}"; do
+ [ "$existing" != "$state" ] || known=1
+ done
+ [ "$known" = 0 ] || continue
+ TREEHOUSE_OWNER_STATES+=("$state")
+ registries=("$home/data/secondmates.md")
+ [ "$state" != "$record_state" ] || registries+=("${DATA:-$home/data}/secondmates.md")
+ for reg in "${registries[@]}"; do
+ [ ! -e "$reg" ] && [ ! -L "$reg" ] && continue
+ [ -f "$reg" ] && [ ! -L "$reg" ] && [ -r "$reg" ] || {
+ echo "REFUSED: local Firstmate registry is unsafe at $reg; nothing was changed" >&2
+ return 1
+ }
+ while IFS= read -r line || [ -n "$line" ]; do
+ case "$line" in
+ "- "*)
+ secondmate_registry_parse_line "$line" || {
+ echo "REFUSED: malformed local Firstmate registry entry in $reg; nothing was changed" >&2
+ return 1
+ }
+ [ "$SECONDMATE_REGISTRY_REMOTE" -eq 0 ] || continue
+ homes+=("$SECONDMATE_REGISTRY_HOME")
+ ;;
+ esac
+ done < "$reg" || return 1
+ done
+ for meta in "$state"/*.meta; do
+ [ -e "$meta" ] || [ -L "$meta" ] || continue
+ [ -f "$meta" ] && [ ! -L "$meta" ] && [ -r "$meta" ] || {
+ echo "REFUSED: cannot inspect slot ownership in $meta" >&2
+ return 1
+ }
+ [ "$(fm_meta_get "$meta" kind)" = secondmate ] || continue
+ [ -z "$(fm_meta_get "$meta" remote_host)" ] || continue
+ child=$(fm_meta_get "$meta" home)
+ [ -n "$child" ] || child=$(fm_meta_get "$meta" worktree)
+ [ -z "$child" ] || homes+=("$child")
+ done
+ done
+}
+
+# Read one native durable lease from the pool's own state. Unlike process
+# occupancy, leased=true survives the worker exiting. Require one exact path
+# entry and a nonempty identity; malformed or missing state proves nothing.
+fm_treehouse_slot_lease() { #
+ local slot=$1 state
+ if [ -d "$slot" ]; then
+ slot=$(CDPATH='' cd -- "$slot" && pwd -P) || return 1
+ fi
+ state="$(dirname "$(dirname "$slot")")/treehouse-state.json"
+ [ -f "$state" ] && [ ! -L "$state" ] && [ -r "$state" ] || return 1
+ jq -er --arg path "$slot" '
+ [.worktrees[] | select(.path == $path)] |
+ select(length == 1) | .[0] |
+ select(.leased == true and (.lease_id | type == "string" and length > 0)) |
+ [.lease_id, (.lease_holder // "")] | @tsv
+ ' "$state"
+}
+
+# Whether a path is structurally a Treehouse-managed slot at all - it appears
+# in the pool's own state file - independent of whether it currently carries a
+# lease. An ordinary linked worktree that merely shares a project's identity
+# has no entry here and is never evidence of anything.
+fm_treehouse_slot_registered() { #
+ local slot=$1 state
+ if [ -d "$slot" ]; then
+ slot=$(CDPATH='' cd -- "$slot" && pwd -P) || return 1
+ fi
+ state="$(dirname "$(dirname "$slot")")/treehouse-state.json"
+ [ -f "$state" ] && [ ! -L "$state" ] && [ -r "$state" ] || return 1
+ jq -e --arg path "$slot" '[.worktrees[] | select(.path == $path)] | length == 1' "$state" >/dev/null
+}
+
+# Before ANY get can reset/reissue a legacy slot, every still-recorded copy in
+# this project pool must already be durably reserved. Older process-only tasks
+# must be torn down or reconciled first; never upgrade their lease by guessing
+# the occupant. The project lock stays held from this scan through publication.
+# Identity match alone only says a record could share this project's pool -
+# fm_treehouse_slot_registered still has to prove the recorded path is actually
+# a managed slot before an absent lease means anything; an ordinary linked
+# worktree recorded incidentally against the same project is never a slot.
+fm_treehouse_require_reserved_records() { #
+ local project=$1 identity other_identity state meta recorded_project path field
+ identity=$(fm_treehouse_project_lock_path "$project") || return 1
+ fm_treehouse_collect_states "$STATE" || return 1
+ for state in "${TREEHOUSE_OWNER_STATES[@]}"; do
+ for meta in "$state"/*.meta; do
+ [ -f "$meta" ] || continue
+ [ -z "$(fm_meta_get "$meta" remote_host)" ] || continue
+ recorded_project=$(fm_meta_get "$meta" project)
+ other_identity=$(fm_treehouse_project_lock_path "$recorded_project" 2>/dev/null) || other_identity=
+ for field in worktree home; do
+ path=$(fm_meta_get "$meta" "$field")
+ [ -n "$path" ] || continue
+ # Matching origin also covers separate clones sharing Treehouse's pool.
+ [ "$identity" = "$other_identity" ] || fm_treehouse_pool_slot "$project" "$path" || continue
+ fm_treehouse_slot_registered "$path" || continue
+ if ! fm_treehouse_slot_lease "$path" >/dev/null 2>&1; then
+ echo "REFUSED: $meta still records $field=$path without a durable Treehouse lease; allocation could re-issue that task's slot. Tear down or reconcile that record before spawning; no slot was requested." >&2
+ return 1
+ fi
+ done
+ done
+ done
+}
+
+fm_treehouse_lease_holder() { #
+ local home
+ home=$(CDPATH='' cd -- "$2" && pwd -P) || return 1
+ printf 'firstmate:%s:%s\n' "$home" "$1"
+}
+
+# Slot ownership has two layers: Treehouse's durable lease excludes a slot from
+# allocation until return, and //.fm-slot-owner binds that lease to
+# the task and canonical home. The marker is outside the checkout so it never
+# dirties the work. New claims require the native lease's exact holder and store
+# its lease_id; teardown validates both before cleanup and conditions return on
+# that identity. Legacy claims have no lease_id and keep record-scan protection.
+# Unleased legacy metadata blocks new allocation until teardown/reconciliation.
fm_treehouse_slot_owner_marker() { #
local worktree=$1 slot
slot=$(CDPATH='' cd -- "$worktree" 2>/dev/null && pwd -P) || return 1
printf '%s/.fm-slot-owner\n' "$(dirname "$slot")"
}
-# Claim a pool slot for a task, replacing whatever the previous holder left.
-# The rename is atomic, so a reader either sees the old claim or the new one.
+# Claim a newly leased slot. A different surviving claim is never overwritten;
+# an aborted allocation retains its native lease for explicit reconciliation.
fm_treehouse_slot_owner_claim() { #
- local worktree=$1 id=$2 home=$3 marker tmp
+ local worktree=$1 id=$2 home=$3 marker tmp lease holder
[ -n "$id" ] || return 1
+ home=$(CDPATH='' cd -- "$home" && pwd -P) || return 1
+ holder=$(fm_treehouse_lease_holder "$id" "$home") || return 1
+ lease=$(fm_treehouse_slot_lease "$worktree") || return 1
+ [ "${lease#*$'\t'}" = "$holder" ] || return 1
+ fm_treehouse_slot_owner_state "$worktree" "$id" "$home"
+ case "$FM_TREEHOUSE_SLOT_OWNER" in absent|mine) ;; *) return 1 ;; esac
marker=$(fm_treehouse_slot_owner_marker "$worktree") || return 1
# Only a plain claim file may be replaced: renaming onto a directory would
# move the new claim inside it and leave the slot reading as unclaimable.
@@ -1294,6 +1429,7 @@ fm_treehouse_slot_owner_claim() { #
{
printf 'task=%s\n' "$id"
printf 'home=%s\n' "$home"
+ printf 'lease_id=%s\n' "${lease%%$'\t'*}"
} > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$marker" 2>/dev/null || { rm -f "$tmp"; return 1; }
}
@@ -1305,13 +1441,15 @@ fm_treehouse_slot_owner_claim() { #
# absent - no claim: the slot was taken before claims existed, or returned since
# unsafe - a claim file exists but cannot be read as a claim
# FM_TREEHOUSE_SLOT_OWNER_ID and FM_TREEHOUSE_SLOT_OWNER_HOME carry the recorded
-# claimant as evidence. The home is reported, never matched: a home that moved
-# must not turn a task's own slot into a refusal.
-fm_treehouse_slot_owner_state() { #
- local worktree=$1 id=$2 marker line owner_id='' owner_home=''
+# claimant as evidence. Task ids are home-local, so both axes must match.
+# A moved home requires reconciliation rather than adopting another home's task.
+fm_treehouse_slot_owner_state() { # [home]
+ local worktree=$1 id=$2 home=${3:-$FM_HOME} marker line owner_id='' owner_home='' lease_id=''
+ local task_count=0 home_count=0 lease_count=0
FM_TREEHOUSE_SLOT_OWNER=unsafe
FM_TREEHOUSE_SLOT_OWNER_ID=
FM_TREEHOUSE_SLOT_OWNER_HOME=
+ FM_TREEHOUSE_SLOT_OWNER_LEASE=
marker=$(fm_treehouse_slot_owner_marker "$worktree") || return 0
if [ ! -e "$marker" ] && [ ! -L "$marker" ]; then
FM_TREEHOUSE_SLOT_OWNER=absent
@@ -1320,16 +1458,26 @@ fm_treehouse_slot_owner_state() { #
[ -f "$marker" ] && [ ! -L "$marker" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
- task=*) owner_id=${line#task=} ;;
- home=*) owner_home=${line#home=} ;;
+ task=*) owner_id=${line#task=}; task_count=$((task_count + 1)) ;;
+ home=*) owner_home=${line#home=}; home_count=$((home_count + 1)) ;;
+ lease_id=*) lease_id=${line#lease_id=}; lease_count=$((lease_count + 1)) ;;
+ *) return 0 ;;
esac
done < "$marker" || return 0
- [ -n "$owner_id" ] || return 0
+ [ "$task_count" = 1 ] && [ "$home_count" = 1 ] && [ "$lease_count" -le 1 ] \
+ && [ -n "$owner_id" ] && [ -n "$owner_home" ] || return 0
+ [ "$lease_count" = 0 ] || [ -n "$lease_id" ] || return 0
# shellcheck disable=SC2034 # Output globals, read by the sourcing caller.
FM_TREEHOUSE_SLOT_OWNER_ID=$owner_id
# shellcheck disable=SC2034 # Output globals, read by the sourcing caller.
FM_TREEHOUSE_SLOT_OWNER_HOME=$owner_home
- if [ "$owner_id" = "$id" ]; then
+ # shellcheck disable=SC2034 # Output globals, read by the sourcing caller.
+ FM_TREEHOUSE_SLOT_OWNER_LEASE=$lease_id
+ home=$(CDPATH='' cd -- "$home" && pwd -P) || return 0
+ if [ -d "$owner_home" ]; then
+ owner_home=$(CDPATH='' cd -- "$owner_home" && pwd -P) || return 0
+ fi
+ if [ "$owner_id" = "$id" ] && [ "$owner_home" = "$home" ]; then
FM_TREEHOUSE_SLOT_OWNER=mine
else
FM_TREEHOUSE_SLOT_OWNER=other
@@ -1339,9 +1487,9 @@ fm_treehouse_slot_owner_state() { #
# Drop a task's own claim once its slot is back in the pool. Never removes
# another task's claim, so a misdirected release cannot strip the evidence that
# protects the slot's real owner.
-fm_treehouse_slot_owner_release() { #
- local worktree=$1 id=$2 marker
- fm_treehouse_slot_owner_state "$worktree" "$id"
+fm_treehouse_slot_owner_release() { # [home]
+ local worktree=$1 id=$2 home=${3:-$FM_HOME} marker
+ fm_treehouse_slot_owner_state "$worktree" "$id" "$home"
[ "$FM_TREEHOUSE_SLOT_OWNER" = mine ] || return 0
marker=$(fm_treehouse_slot_owner_marker "$worktree") || return 0
rm -f "$marker" 2>/dev/null || true
diff --git a/docs/architecture.md b/docs/architecture.md
index 7af90ab2e00..e12d8a4b7f6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -380,7 +380,8 @@ A later merged poll consumes only that matching persisted value; with no match i
[`bin/fm-merge-authority-lib.sh`](../bin/fm-merge-authority-lib.sh)'s header owns resolution, private atomic persistence, identity-checked consumption, and retirement, while only the merge path gates on the answer.
Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned.
A pool worktree is only returned after teardown passes the slot-ownership proof: a contradictory task record or a supported live endpoint refuses without touching either task, and no discard authority relaxes that.
-A slot's own owner claim, written by the spawn that takes it under the allocation lock and owned by [`bin/fm-wake-lib.sh`](../bin/fm-wake-lib.sh), covers a slot reassigned to a task that left no record the scan could reach: a claim naming a different task releases nothing - teardown warns, names the claimant, and finishes only the task's own cleanup - because Treehouse's own live process lease cannot answer ownership once the worker's exit releases it.
+A slot's own owner claim, written by the spawn that takes it under the allocation lock and owned by [`bin/fm-wake-lib.sh`](../bin/fm-wake-lib.sh), covers a slot reassigned to a task that left no record the scan could reach: spawn binds the claim to Treehouse's own durable `get --lease --lease-holder` lease rather than the process lease that a worker's exit releases, and a claim naming a different task, or one whose bound lease no longer matches, refuses outright and touches neither task - never warn-and-finish-own-cleanup.
+Before any slot is (re-)issued, allocation also refuses when a still-recorded `state/*.meta` anywhere reachable - including secondmate homes - names that slot without a durable lease of its own, so a stale, never-torn-down record can no longer be silently handed to the next spawn.
Allocation and return serialize on one project lock per machine-local Firstmate tree: every home reachable through local parent links shares that lock, and a home seeded from another machine anchors its own, because a lock taken on this filesystem is neither held nor observable across that boundary.
Before the worktree is returned, teardown concludes the task's own no-mistakes run when it is parked at a gate, including a run whose head the task copy cannot resolve - the shared runs-ledger continuation proof is the only recognition for that case, so cleanup never orphans a parked run the pipeline advanced past the submitted head.
[`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, slot-ownership proof, endpoint-close refusal, PR-discovery fallback, pre-teardown run conclusion, and stale-lock recovery procedure; [`tests/fm-teardown-endpoint-safety.test.sh`](../tests/fm-teardown-endpoint-safety.test.sh) and [`tests/fm-secondmate-safety.test.sh`](../tests/fm-secondmate-safety.test.sh) pin the slot-collision boundary.
diff --git a/tests/fixtures.sh b/tests/fixtures.sh
index 559cd661eef..26ff32dea0f 100755
--- a/tests/fixtures.sh
+++ b/tests/fixtures.sh
@@ -120,6 +120,12 @@ case "${1:-}" in
;;
has-session|new-session|new-window|kill-window|set-window-option) exit 0 ;;
send-keys)
+ # Model only the shell's lease-acquire command; never execute a worker.
+ for a in "$@"; do
+ case "$a" in
+ 'fm_slot=$(treehouse get --lease '* ) bash -c "$a" || true ;;
+ esac
+ done
if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then
prev=
for a in "$@"; do
@@ -299,7 +305,30 @@ fm_test_make_spawn_fakebin() {
shift
fakebin=$(fm_fakebin "$dir")
fm_test_fake_tmux_spawn "$fakebin"
- fm_fake_exit0 "$fakebin" treehouse "$@"
+ fm_fake_exit0 "$fakebin" "$@"
+ cat > "$fakebin/treehouse" <<'SH'
+#!/usr/bin/env bash
+set -eu
+[ "${1:-}" = get ] || exit 0
+holder=
+while [ "$#" -gt 0 ]; do
+ case "$1" in --lease-holder) holder=$2; shift ;; esac
+ shift
+done
+path=${FM_FAKE_PANE_PATH:-}
+if [ -n "$path" ]; then
+ state="$(dirname "$(dirname "$path")")/treehouse-state.json"
+ if [ -f "$state" ]; then
+ jq --arg path "$path" --arg holder "$holder" '
+ (.worktrees[] | select(.path == $path)) |=
+ (. + {leased:true, lease_id:"fixture-lease", lease_holder:$holder})
+ ' "$state" > "$state.tmp"
+ mv "$state.tmp" "$state"
+ fi
+fi
+printf '%s\n' "$path"
+SH
+ chmod +x "$fakebin/treehouse"
printf '%s\n' "$fakebin"
}
diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh
index 2d39f3607ca..efa01a67cc7 100755
--- a/tests/fm-spawn-pool-base-freshen.test.sh
+++ b/tests/fm-spawn-pool-base-freshen.test.sh
@@ -183,7 +183,9 @@ test_stale_pool_base_refreshes_before_branching() {
"$branch_head" "$current" "$(cat "$POOL_DIR/advanced-main.txt")"
fi
- id='pool-current-base-repeat-r1'
+ # Simulate completion of the first fixture task before reusing its copy.
+ rm "$HOME_DIR/state/$id.meta"
+ id='pool-current-base-repeat-r1'
fm_test_spawn_brief "$HOME_DIR" "$id"
out=$(run_spawn "$id" --mode no-mistakes --yolo off)
status=$?
@@ -743,6 +745,46 @@ test_pool_slot_claim_follows_the_spawn_outcome() {
pass "a Treehouse slot claim names the launched task, refuses when unclaimable, and is dropped by a locked abort"
}
+test_recorded_slot_blocks_reissue_before_get() {
+ local place rec id out status owner_home old_head old_state
+ for place in local registered metadata; do
+ id="slot-reissue-$place"
+ rec=$(make_case "reissue-$place" "$id")
+ read_case_record "$rec"
+ lay_out_as_pool_slot
+ owner_home=$HOME_DIR
+ if [ "$place" != local ]; then
+ owner_home="$CASE_DIR/secondmate"
+ mkdir -p "$owner_home/state" "$owner_home/data"
+ if [ "$place" = registered ]; then
+ printf -- '- mate - Test (home: %s; scope: test; projects: project; added 2026-09-13)\n' \
+ "$owner_home" > "$HOME_DIR/data/secondmates.md"
+ else
+ fm_write_meta "$HOME_DIR/state/mate.meta" "kind=secondmate" "home=$owner_home"
+ fi
+ fi
+ # The completed task still records this process-free, clean slot. A
+ # different claimant reflects the reported already-reissued-slot case.
+ fm_write_meta "$owner_home/state/finished-task.meta" \
+ "project=$PROJECT_DIR" "worktree=$POOL_DIR" "kind=ship"
+ printf 'task=successor\nhome=%s\n' "$CASE_DIR/other-home" > "$SLOT_CLAIM"
+ old_head=$(git -C "$POOL_DIR" rev-parse HEAD)
+ old_state=$(cat "$CASE_DIR/slots/treehouse-state.json")
+ out=$(FM_FAKE_LAUNCH_LOG="$CASE_DIR/launch.log" run_spawn "$id" --scout)
+ status=$?
+ [ "$status" -ne 0 ] || fail "$place: spawn reused a still-recorded slot"
+ assert_contains "$out" "$owner_home/state/finished-task.meta" "$place: refusal must name the blocking record"
+ assert_contains "$out" "no slot was requested" "$place: refusal must precede allocation"
+ [ ! -e "$CASE_DIR/launch.log" ] || fail "$place: spawn reached the task shell before refusal"
+ [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "$place: refused spawn published metadata"
+ [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$old_head" ] || fail "$place: spawn reset the old slot"
+ [ "$(cat "$CASE_DIR/slots/treehouse-state.json")" = "$old_state" ] || fail "$place: spawn changed the pool lease"
+ assert_grep 'task=successor' "$SLOT_CLAIM" "$place: spawn overwrote the successor claim"
+ done
+ pass "recorded local and secondmate slots refuse before get can reissue or reset them"
+}
+
+test_recorded_slot_blocks_reissue_before_get
test_remote_seeded_home_spawns_from_treehouse_pool
test_pool_slot_claim_follows_the_spawn_outcome
test_linked_spawning_home_rejects_primary_before_refresh
diff --git a/tests/fm-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh
index 6f80e3078e0..0c4b929f0c9 100755
--- a/tests/fm-tangle-guard.test.sh
+++ b/tests/fm-tangle-guard.test.sh
@@ -279,8 +279,10 @@ test_spawn_tmux_window_construction() {
"must disable allow-rename on the spawned window"
# Bug 2 fix (b): treehouse-get and the worktree wait loop target the stable id.
- assert_grep "send-keys -t @spawnwid treehouse get Enter" "$rec" \
+ assert_grep "send-keys -t @spawnwid fm_slot=\$(treehouse get --lease --lease-holder" "$rec" \
"treehouse get must be sent to the stable window id"
+ assert_grep "cd -- \"\$fm_slot\" Enter" "$rec" \
+ "the treehouse lease acquisition line sent to the stable window id must cd into the leased slot"
assert_grep "display-message -p -t @spawnwid #{pane_current_path}" "$rec" \
"the worktree wait loop must query the stable window id, not the name"
diff --git a/tests/fm-teardown-endpoint-safety.test.sh b/tests/fm-teardown-endpoint-safety.test.sh
index 4002cf4df3e..de9c9afca82 100755
--- a/tests/fm-teardown-endpoint-safety.test.sh
+++ b/tests/fm-teardown-endpoint-safety.test.sh
@@ -862,25 +862,24 @@ test_remote_layout_homes_serialize_on_one_project_lock() {
# worker exited, its slot was granted to another task, and that task leaves no
# record this home can enumerate. Nothing in the record scan contradicts the
# stale worktree= line, so the slot's own owner claim is the only evidence that
-# it was reassigned. The slot is no longer this task's, so teardown finishes the
-# task's own cleanup and leaves the slot - its worker, its copy, its claim -
-# exactly as it found it.
+# it was reassigned. Teardown must preserve both the occupant and the stale
+# task's durable record for reconciliation.
assert_reassigned_slot_left_alone() { #
local dir=$1 id=$2 other=$3 description=$4
- assert_absent "$dir/home/state/$id.meta" "$description: the stale task's own record was not removed"
+ assert_present "$dir/home/state/$id.meta" "$description: the stale task record was removed"
assert_present "$dir/pool/1/.fm-slot-owner" "$description: another task's slot claim was removed"
assert_contains "$(cat "$dir/pool/1/.fm-slot-owner")" "task=$other" \
"$description: another task's slot claim was rewritten"
assert_present "$dir/pool/1/project/.git" "$description: the reassigned slot's checkout was removed"
- ! grep -Fq "treehouse " "$dir/runtime.log" \
- || fail "$description: the reassigned slot was returned to the pool: $(cat "$dir/runtime.log")"
+ [ ! -s "$dir/runtime.log" ] \
+ || fail "$description: runtime cleanup ran before refusal: $(cat "$dir/runtime.log")"
assert_contains "$(cat "$dir/stderr")" "$other" \
"$description: the warning should name the task the slot was reassigned to"
assert_contains "$(cat "$dir/stderr")" "reassigned" \
"$description: the warning should name the reassignment as the cause"
}
-test_reassigned_pool_slot_finishes_own_cleanup_without_touching_the_slot() {
+test_reassigned_pool_slot_refuses_without_touching_either_task() {
local dir id=stale-task other=reassigned-task worker rc
# Dirty slot, --force, and a live worker inside it: --force authorizes
@@ -903,7 +902,7 @@ test_reassigned_pool_slot_finishes_own_cleanup_without_touching_the_slot() {
rc=$?
set -e
- [ "$rc" -eq 0 ] || fail "teardown of a task whose slot was reassigned failed: $(cat "$dir/stderr")"
+ [ "$rc" -ne 0 ] || fail "teardown accepted a reassigned slot"
kill -0 "$worker" 2>/dev/null || fail "teardown killed the worker holding the reassigned pool slot"
assert_present "$dir/worktree/sentinel" "teardown reset a pool slot another task had claimed"
assert_reassigned_slot_left_alone "$dir" "$id" "$other" "dirty reassigned slot with --force"
@@ -935,7 +934,7 @@ test_reassigned_pool_slot_finishes_own_cleanup_without_touching_the_slot() {
"$TEARDOWN" "$id" > "$dir/stdout" 2> "$dir/stderr"
rc=$?
set -e
- [ "$rc" -eq 0 ] || fail "teardown of a clean ship task whose slot was reassigned failed: $(cat "$dir/stderr")"
+ [ "$rc" -ne 0 ] || fail "teardown accepted a clean reassigned slot"
kill -0 "$worker" 2>/dev/null || fail "teardown killed the worker holding the clean reassigned pool slot"
assert_reassigned_slot_left_alone "$dir" "$id" "$other" "clean reassigned slot without --force"
kill "$worker" 2>/dev/null || true
@@ -963,7 +962,7 @@ test_reassigned_pool_slot_finishes_own_cleanup_without_touching_the_slot() {
assert_contains "$(cat "$dir/stderr")" "$dir/pool/1/.fm-slot-owner" \
"unreadable-claim refusal should name the claim file to inspect"
- pass "fm-teardown: a pool slot claimed by another task is left alone while the task's own cleanup finishes"
+ pass "fm-teardown: a reassigned slot refuses and preserves both tasks"
}
# The two states that must never become a false refusal: the task's own claim,
@@ -1385,7 +1384,7 @@ test_bare_relative_origin_shares_project_lock_with_clone
test_reused_pool_slot_refuses_before_touching_the_other_task
test_cross_home_pool_slot_collision_refuses
test_sole_slot_record_still_tears_down
-test_reassigned_pool_slot_finishes_own_cleanup_without_touching_the_slot
+test_reassigned_pool_slot_refuses_without_touching_either_task
test_own_and_absent_slot_claims_still_tear_down
test_recorded_endpoint_that_changed_directory_still_tears_down
test_project_lock_anchors_at_the_local_root_across_home_layouts
From 384c2b084d1c25e4b7d480c89843f09d9c45541f Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:02:54 +0200
Subject: [PATCH 02/10] no-mistakes(review): Return aborted spawn leases,
preflight home-seed, fix state scan
---
bin/fm-home-seed.sh | 20 ++++-
bin/fm-spawn.sh | 46 ++++++++----
bin/fm-wake-lib.sh | 76 +++++++++++--------
tests/fixtures.sh | 23 ++++++
tests/fm-secondmate-safety.test.sh | 96 ++++++++++++++++++++++++
tests/fm-spawn-pool-base-freshen.test.sh | 17 ++++-
6 files changed, 229 insertions(+), 49 deletions(-)
diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh
index 6693ab1df74..4f924f60636 100755
--- a/bin/fm-home-seed.sh
+++ b/bin/fm-home-seed.sh
@@ -49,6 +49,8 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent"
. "$SCRIPT_DIR/fm-secondmate-charter-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
+# shellcheck source=bin/fm-backend.sh
+. "$SCRIPT_DIR/fm-backend.sh"
usage() {
echo "usage: fm-home-seed.sh {...|--no-projects}" >&2
@@ -388,15 +390,31 @@ seeded_origin_url() {
}
acquire_treehouse_home() {
- local id=$1 home
+ local id=$1 home lock
# Durably lease a firstmate worktree from the pool. The lease persists with no
# live process and is skipped by later get/prune, so the home survives restarts
# until teardown or rollback returns it. treehouse prints only the worktree path
# to stdout (banners go to stderr), so command substitution captures the path.
+ # The get shares bin/fm-spawn.sh's Treehouse project lock and legacy-record
+ # preflight, so it never reissues a Firstmate slot a task record still names.
+ lock=$(fm_treehouse_project_lock_path "$FM_ROOT") || {
+ echo "error: could not resolve the shared Treehouse project lock for $FM_ROOT" >&2
+ return 1
+ }
+ fm_lock_try_acquire "$lock" || {
+ echo "error: another Treehouse slot allocation or return is in progress for $FM_ROOT; refusing to race it" >&2
+ return 1
+ }
+ if ! fm_treehouse_require_reserved_records "$FM_ROOT"; then
+ fm_lock_release "$lock"
+ return 1
+ fi
home=$(cd "$FM_ROOT" && treehouse get --lease --lease-holder "$id") || {
+ fm_lock_release "$lock"
echo "error: treehouse get --lease failed to lease a firstmate home" >&2
return 1
}
+ fm_lock_release "$lock"
[ -n "$home" ] || { echo "error: treehouse get --lease did not report a firstmate home" >&2; return 1; }
printf '%s\n' "$home"
}
diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh
index 9d4ca252468..19d3510086a 100755
--- a/bin/fm-spawn.sh
+++ b/bin/fm-spawn.sh
@@ -132,8 +132,10 @@
# task metadata publication. Teardown holds that same lock while proving and
# returning a slot, so allocation cannot reuse a slot before its owner record
# is published. Slot leasing, legacy-record preflight, and owner binding are
-# owned by bin/fm-wake-lib.sh. An aborted spawn leaves its native lease reserved
-# for reconciliation; it never returns a copy whose work was not inspected.
+# owned by bin/fm-wake-lib.sh. A spawn that aborts while it still holds that
+# lock, with no surviving record, has launched no worker, so it returns its own
+# lease and drops its claim; an abort after the lock is released leaves both
+# reserved until they are reconciled by hand.
# The local root is whatever bin/fm-wake-lib.sh's
# fm_firstmate_root_home resolves, so a home seeded from another machine anchors
# that lock itself rather than failing to resolve one;
@@ -1078,6 +1080,7 @@ SPAWN_TASK_SET_LOCK_HELD=0
SPAWN_TREEHOUSE_PROJECT_LOCK=
SPAWN_TREEHOUSE_PROJECT_LOCK_HELD=0
SPAWN_SLOT_CLAIMED=0
+SPAWN_LEASE_HOLDER=
RELAUNCH_REPLACEMENT_PENDING=0
RELAUNCH_REPLACEMENT_BUSY_GEN=
RELAUNCH_REPLACEMENT_HARNESS=
@@ -1113,6 +1116,17 @@ parse_orca_worktree_result() {
fi
}
+spawn_return_aborted_lease() { #
+ local slot=$1 lease
+ [ -n "$slot" ] && fm_treehouse_pool_slot "$PROJ_ABS" "$slot" || return 0
+ lease=$(fm_treehouse_slot_lease "$slot") || return 0
+ [ "${lease#*$'\t'}" = "$SPAWN_LEASE_HOLDER" ] || return 0
+ if ! (cd "$PROJ_ABS" && treehouse return --force --if-lease-holder "$SPAWN_LEASE_HOLDER" "$slot") >/dev/null 2>&1; then
+ echo "warning: could not return task $ID's aborted Treehouse lease on $slot; it stays reserved under holder $SPAWN_LEASE_HOLDER until it is returned by hand" >&2
+ return 1
+ fi
+}
+
spawn_abort_cleanup() {
local status=$?
if [ "$RELAUNCH_REPLACEMENT_PENDING" = 1 ] &&
@@ -1209,21 +1223,27 @@ spawn_abort_cleanup() {
SPAWN_META_LOCK_HELD=0
fm_lock_release "$SPAWN_META_LOCK" || true
fi
- # A spawn that aborts after claiming its slot but before its record survives
- # must not leave a claim naming a task no record describes. The release is a
- # read-then-remove, so it runs only while the project lock that wrote the
- # claim is still held (aborts before metadata publication); a later abort has
- # already released that lock and leaves the claim for the next spawn's
- # atomic replacement rather than racing it. The release itself never removes
- # another task's claim.
- if [ "$SPAWN_SLOT_CLAIMED" = 1 ] && [ -n "${WT:-}" ] &&
- [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ] &&
- fm_treehouse_pool_slot "$PROJ_ABS" "$WT"; then
+ # A spawn that aborts after taking its lease but before its record survives
+ # must leave neither a lease nor a claim naming a task no record describes.
+ # While the project lock that allocated the slot is still held no worker has
+ # been launched, so the lease is returned - conditioned on this task's own
+ # holder, so a slot Treehouse has handed elsewhere is never touched - and the
+ # claim, a read-then-remove, is dropped. A later abort has already released
+ # that lock; its lease and claim stay reserved, and nothing reissues that slot
+ # until it is reconciled by hand. Neither step ever removes another task's
+ # lease or claim.
+ if [ -n "$SPAWN_LEASE_HOLDER" ] && [ "$SPAWN_TREEHOUSE_PROJECT_LOCK_HELD" = 1 ] \
+ && [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ]; then
+ spawn_return_aborted_lease "${WT:-${last_seen:-}}" || true
+ fi
+ if [ "$SPAWN_SLOT_CLAIMED" = 1 ] && [ -n "${WT:-}" ] \
+ && [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ] \
+ && fm_treehouse_pool_slot "$PROJ_ABS" "$WT"; then
SPAWN_SLOT_CLAIMED=0
if [ "$SPAWN_TREEHOUSE_PROJECT_LOCK_HELD" = 1 ]; then
fm_treehouse_slot_owner_release "$WT" "$ID" || true
else
- echo "warning: leaving task $ID's slot claim on $WT in place; the Treehouse project lock is no longer held, so the next spawn's claim replaces it" >&2
+ echo "warning: leaving task $ID's slot claim and Treehouse lease (holder $SPAWN_LEASE_HOLDER) on $WT in place; the Treehouse project lock is no longer held, so that slot stays reserved and is not reissued until it is reconciled by hand" >&2
fi
fi
if [ "$SPAWN_TREEHOUSE_PROJECT_LOCK_HELD" = 1 ]; then
diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh
index 1db5c31358a..3371eb9794e 100755
--- a/bin/fm-wake-lib.sh
+++ b/bin/fm-wake-lib.sh
@@ -1249,21 +1249,26 @@ fm_treehouse_pool_slot() { #
[ "$project_common" = "$slot_common" ]
}
-# Enumerate discoverable local homes for slot ownership, including the invoking
-# home's overrides, its parent chain, registered descendants, and secondmate
-# metadata not yet reflected in a registry. Remote routes are another filesystem.
-# Missing state directories have no records; unreadable or unsafe records fail
-# closed instead of turning an incomplete scan into allocation authority.
+# Enumerate discoverable local homes for slot ownership: every home's own
+# state, the caller's record state and data overrides, its parent chain,
+# registered descendants, and secondmate metadata not yet reflected in a
+# registry. Remote routes are another filesystem. Each home is walked once and
+# each state scanned once, so a record state belonging to another home never
+# stands in for the invoking home's own. Missing state directories have no
+# records; unreadable or unsafe records fail closed instead of turning an
+# incomplete scan into allocation authority.
fm_treehouse_collect_states() { #
- local record_state=$1 root home state reg line child existing known i=0 meta
- local -a homes registries
+ local record_state=$1 root fm_home home state reg line child existing known i=0 meta
+ local -a homes visited states registries
# shellcheck source=bin/fm-secondmate-registry-lib.sh
. "$FM_WAKE_LIB_DIR/fm-secondmate-registry-lib.sh"
root=$(fm_firstmate_root_home "$FM_HOME") || {
echo "REFUSED: cannot resolve the root Firstmate home; nothing was changed" >&2
return 1
}
- homes=("$root" "$FM_HOME")
+ fm_home=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) || return 1
+ homes=("$fm_home" "$root")
+ visited=()
TREEHOUSE_OWNER_STATES=()
while [ "$i" -lt "${#homes[@]}" ]; do
home=$(CDPATH='' cd -- "${homes[$i]}" 2>/dev/null && pwd -P) || {
@@ -1271,23 +1276,18 @@ fm_treehouse_collect_states() { #
return 1
}
i=$((i + 1))
- state="$home/state"
- [ "$home" != "$(CDPATH='' cd -- "$FM_HOME" && pwd -P)" ] || state=$record_state
- [ ! -e "$state" ] && [ ! -L "$state" ] || {
- [ -d "$state" ] && [ -r "$state" ] && [ -x "$state" ] || {
- echo "REFUSED: local Firstmate state is unreadable at $state" >&2
- return 1
- }
- state=$(CDPATH='' cd -- "$state" && pwd -P) || return 1
- }
known=0
- for existing in "${TREEHOUSE_OWNER_STATES[@]+"${TREEHOUSE_OWNER_STATES[@]}"}"; do
- [ "$existing" != "$state" ] || known=1
+ for existing in "${visited[@]+"${visited[@]}"}"; do
+ [ "$existing" != "$home" ] || known=1
done
[ "$known" = 0 ] || continue
- TREEHOUSE_OWNER_STATES+=("$state")
+ visited+=("$home")
+ states=("$home/state")
registries=("$home/data/secondmates.md")
- [ "$state" != "$record_state" ] || registries+=("${DATA:-$home/data}/secondmates.md")
+ if [ "$home" = "$fm_home" ]; then
+ states=("$record_state" "$home/state")
+ registries+=("${DATA:-$home/data}/secondmates.md")
+ fi
for reg in "${registries[@]}"; do
[ ! -e "$reg" ] && [ ! -L "$reg" ] && continue
[ -f "$reg" ] && [ ! -L "$reg" ] && [ -r "$reg" ] || {
@@ -1307,17 +1307,31 @@ fm_treehouse_collect_states() { #
esac
done < "$reg" || return 1
done
- for meta in "$state"/*.meta; do
- [ -e "$meta" ] || [ -L "$meta" ] || continue
- [ -f "$meta" ] && [ ! -L "$meta" ] && [ -r "$meta" ] || {
- echo "REFUSED: cannot inspect slot ownership in $meta" >&2
+ for state in "${states[@]}"; do
+ [ -e "$state" ] || [ -L "$state" ] || continue
+ [ -d "$state" ] && [ -r "$state" ] && [ -x "$state" ] || {
+ echo "REFUSED: local Firstmate state is unreadable at $state" >&2
return 1
}
- [ "$(fm_meta_get "$meta" kind)" = secondmate ] || continue
- [ -z "$(fm_meta_get "$meta" remote_host)" ] || continue
- child=$(fm_meta_get "$meta" home)
- [ -n "$child" ] || child=$(fm_meta_get "$meta" worktree)
- [ -z "$child" ] || homes+=("$child")
+ state=$(CDPATH='' cd -- "$state" && pwd -P) || return 1
+ known=0
+ for existing in "${TREEHOUSE_OWNER_STATES[@]+"${TREEHOUSE_OWNER_STATES[@]}"}"; do
+ [ "$existing" != "$state" ] || known=1
+ done
+ [ "$known" = 0 ] || continue
+ TREEHOUSE_OWNER_STATES+=("$state")
+ for meta in "$state"/*.meta; do
+ [ -e "$meta" ] || [ -L "$meta" ] || continue
+ [ -f "$meta" ] && [ ! -L "$meta" ] && [ -r "$meta" ] || {
+ echo "REFUSED: cannot inspect slot ownership in $meta" >&2
+ return 1
+ }
+ [ "$(fm_meta_get "$meta" kind)" = secondmate ] || continue
+ [ -z "$(fm_meta_get "$meta" remote_host)" ] || continue
+ child=$(fm_meta_get "$meta" home)
+ [ -n "$child" ] || child=$(fm_meta_get "$meta" worktree)
+ [ -z "$child" ] || homes+=("$child")
+ done
done
done
}
@@ -1407,7 +1421,7 @@ fm_treehouse_slot_owner_marker() { #
}
# Claim a newly leased slot. A different surviving claim is never overwritten;
-# an aborted allocation retains its native lease for explicit reconciliation.
+# bin/fm-spawn.sh owns returning the lease of an allocation that aborts.
fm_treehouse_slot_owner_claim() { #
local worktree=$1 id=$2 home=$3 marker tmp lease holder
[ -n "$id" ] || return 1
diff --git a/tests/fixtures.sh b/tests/fixtures.sh
index 26ff32dea0f..7d27a7bad26 100755
--- a/tests/fixtures.sh
+++ b/tests/fixtures.sh
@@ -309,6 +309,29 @@ fm_test_make_spawn_fakebin() {
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
set -eu
+if [ "${1:-}" = return ]; then
+ shift
+ holder= path=
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --if-lease-holder) holder=$2; shift ;;
+ --if-lease-id) shift ;;
+ --*) ;;
+ *) path=$1 ;;
+ esac
+ shift
+ done
+ [ -n "$holder" ] || exit 0
+ state="$(dirname "$(dirname "$path")")/treehouse-state.json"
+ [ -f "$state" ] || exit 1
+ jq -e --arg path "$path" --arg holder "$holder" \
+ 'any(.worktrees[]; .path == $path and .lease_holder == $holder)' "$state" >/dev/null || exit 1
+ jq --arg path "$path" '
+ (.worktrees[] | select(.path == $path)) |= del(.leased, .lease_id, .lease_holder)
+ ' "$state" > "$state.tmp"
+ mv "$state.tmp" "$state"
+ exit 0
+fi
[ "${1:-}" = get ] || exit 0
holder=
while [ "$#" -gt 0 ]; do
diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh
index 7a69e15fe86..5efb6ee9840 100755
--- a/tests/fm-secondmate-safety.test.sh
+++ b/tests/fm-secondmate-safety.test.sh
@@ -1990,6 +1990,100 @@ EOF
pass "forced secondmate teardown refuses duplicated descendant pool slots"
}
+test_secondmate_force_teardown_refuses_child_slot_recorded_by_parent() {
+ local home subhome childproj childwt fakebin log err rc rec
+ home="$TMP_ROOT/force-parent-slot-home"
+ subhome="$TMP_ROOT/force-parent-slot-subhome"
+ childproj="$subhome/projects/alpha"
+ childwt="$TMP_ROOT/force-parent-slot-pool/1/alpha"
+ err="$TMP_ROOT/force-parent-slot.err"
+ mkdir -p "$home/state" "$home/data" "$subhome/state" "$(dirname "$childwt")"
+ fm_git_worktree "$childproj" "$childwt" parent-slot-child
+ printf '{"worktrees":[{"name":"1","path":"%s"}]}\n' "$childwt" \
+ > "$TMP_ROOT/force-parent-slot-pool/treehouse-state.json"
+ printf 'domain\n' > "$subhome/.fm-secondmate-home"
+ cat > "$home/state/domain.meta" < "$home/data/secondmates.md"
+ # The secondmate's landed child was never torn down, and its unclaimed slot
+ # now belongs to a task recorded in the parent's own state.
+ for rec in "$subhome/state/stale-child" "$home/state/live-crew"; do
+ cat > "$rec.meta" </dev/null 2>"$err"
+ rc=$?
+ set -e
+ [ "$rc" -ne 0 ] || fail "forced secondmate teardown returned a child slot the parent's task records"
+ [ -d "$childwt" ] || fail "forced secondmate teardown removed the parent's task slot"
+ [ -e "$subhome/state/stale-child.meta" ] || fail "forced secondmate teardown removed the stale child record"
+ [ -e "$home/state/live-crew.meta" ] || fail "forced secondmate teardown removed the parent's task record"
+ grep -F 'kill-window' "$log" >/dev/null && fail "forced secondmate teardown killed a child before detecting its slot collision"
+ grep -F 'treehouse return' "$log" >/dev/null && fail "forced secondmate teardown returned the parent's task slot"
+ grep -F 'live-crew' "$err" >/dev/null \
+ || fail "forced secondmate teardown did not name the parent's task holding the slot: $(cat "$err")"
+ pass "forced secondmate teardown refuses a child slot recorded in the parent's own state"
+}
+
+test_home_seed_refuses_recorded_unleased_firstmate_slot() {
+ local home acquired pool slot fakebin log lease err
+ home="$TMP_ROOT/dash-recorded-slot-home"
+ acquired="$TMP_ROOT/dash-recorded-slot-acquired"
+ pool="$TMP_ROOT/dash-recorded-slot-pool"
+ err="$TMP_ROOT/dash-recorded-slot.err"
+ mkdir -p "$home/projects" "$home/data" "$home/state" "$pool/1/firstmate"
+ home=$(cd "$home" && pwd -P)
+ pool=$(cd "$pool" && pwd -P)
+ slot="$pool/1/firstmate"
+ fm_git_init_commit "$home/projects/alpha"
+ fm_git_add_origin "$home/projects/alpha" "$TMP_ROOT/remotes/dash-recorded-slot-alpha.git"
+ printf '%s\n' '- alpha [direct-PR] - alpha project (added 2026-06-22)' > "$home/data/projects.md"
+ printf '{"worktrees":[{"name":"1","path":"%s"}]}\n' "$slot" > "$pool/treehouse-state.json"
+ # A landed task on the Firstmate project itself was never torn down, and its
+ # process-only slot is free for the next get again.
+ printf 'project=%s\nworktree=%s\nkind=ship\n' "$ROOT" "$slot" > "$home/state/stale-crew.meta"
+ fakebin=$(make_fake_tmux "$TMP_ROOT/dash-recorded-slot-fake")
+ log="$TMP_ROOT/dash-recorded-slot-fake/tmux.log"
+ lease="$TMP_ROOT/dash-recorded-slot-fake/lease"
+
+ if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TREEHOUSE_HOME="$acquired" FM_FAKE_TMUX_LOG="$log" \
+ FM_FAKE_TREEHOUSE_LEASE_FILE="$lease" \
+ FM_SECONDMATE_CHARTER='dash recorded scope' FM_SECONDMATE_SCOPE='dash recorded scope' \
+ "$ROOT/bin/fm-home-seed.sh" dash - alpha >/dev/null 2>"$err"; then
+ fail "home seeding leased a Firstmate slot while another task still records it"
+ fi
+ grep -F "$home/state/stale-crew.meta" "$err" >/dev/null \
+ || fail "home seeding did not name the record still holding the slot: $(cat "$err")"
+ grep -F 'treehouse get' "$log" >/dev/null && fail "home seeding requested a slot before refusing"
+ [ ! -e "$lease" ] || fail "home seeding took a Treehouse lease despite the recorded slot"
+ [ ! -e "$acquired" ] || fail "home seeding provisioned a home despite the recorded slot"
+ [ ! -e "$home/data/secondmates.md" ] || fail "home seeding registered a secondmate despite the recorded slot"
+ [ -e "$home/state/stale-crew.meta" ] || fail "home seeding removed the stale task record"
+ pass "home seeding refuses to lease while a stale record names an unleased Firstmate slot"
+}
+
test_secondmate_force_teardown_preserves_child_on_unproven_lock() {
local home subhome childproj childwt fakebin log err rc lock
home="$TMP_ROOT/force-lock-home"
@@ -3011,6 +3105,8 @@ test_secondmate_teardown_refuses_failed_leased_home_return
test_secondmate_teardown_removes_plain_clone_home_without_treehouse_return
test_secondmate_force_teardown_discards_child_work
test_secondmate_force_teardown_refuses_duplicated_child_slot
+test_secondmate_force_teardown_refuses_child_slot_recorded_by_parent
+test_home_seed_refuses_recorded_unleased_firstmate_slot
test_secondmate_force_teardown_preserves_child_on_unproven_lock
test_secondmate_force_teardown_allows_non_state_operational_dir_symlinks_inside_home
test_secondmate_force_teardown_refuses_operational_dir_symlink_outside_home
diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh
index efa01a67cc7..9b6608a4939 100755
--- a/tests/fm-spawn-pool-base-freshen.test.sh
+++ b/tests/fm-spawn-pool-base-freshen.test.sh
@@ -691,10 +691,16 @@ lay_out_as_pool_slot() {
SLOT_CLAIM="$slot_root/1/.fm-slot-owner"
}
+slot_leased() {
+ jq -e --arg path "$POOL_DIR" '.worktrees[] | select(.path == $path) | .leased == true' \
+ "$CASE_DIR/slots/treehouse-state.json" >/dev/null
+}
+
# The spawn side of the slot-owner claim that bin/fm-teardown.sh later reads:
-# a launched task's claim names it, a slot that cannot be claimed refuses before
-# anything is published, and an abort while the allocation lock is still held
-# leaves no claim naming a task with no record.
+# a launched task's claim names it and keeps its lease, a slot that cannot be
+# claimed refuses before anything is published, and an abort while the
+# allocation lock is still held leaves neither a lease nor a claim naming a
+# task with no record.
test_pool_slot_claim_follows_the_spawn_outcome() {
local rec id out status before
@@ -712,6 +718,7 @@ test_pool_slot_claim_follows_the_spawn_outcome() {
|| fail "the slot claim does not name the spawned task: $(cat "$SLOT_CLAIM")"
grep -Fxq -- "home=$HOME_DIR" "$SLOT_CLAIM" \
|| fail "the slot claim does not name the spawning home: $(cat "$SLOT_CLAIM")"
+ slot_leased || fail "a launched spawn gave up its Treehouse lease"
id='pool-slot-unclaimable-r1'
rec=$(make_case slot-unclaimable "$id")
@@ -728,6 +735,7 @@ test_pool_slot_claim_follows_the_spawn_outcome() {
[ ! -e "$HOME_DIR/state/$id.meta" ] || fail "spawn published a record for an unclaimable slot"
[ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \
|| fail "spawn moved the slot's HEAD after failing to claim it"
+ ! slot_leased || fail "a spawn refused for an unclaimable slot kept its Treehouse lease"
id='pool-slot-claim-aborted-r1'
rec=$(make_originless_case slot-claim-aborted "$id")
@@ -742,7 +750,8 @@ test_pool_slot_claim_follows_the_spawn_outcome() {
[ ! -e "$HOME_DIR/state/$id.meta" ] || fail "the aborted spawn published task metadata"
[ ! -e "$SLOT_CLAIM" ] && [ ! -L "$SLOT_CLAIM" ] \
|| fail "the aborted spawn left a slot claim naming a task with no record: $(cat "$SLOT_CLAIM")"
- pass "a Treehouse slot claim names the launched task, refuses when unclaimable, and is dropped by a locked abort"
+ ! slot_leased || fail "the aborted spawn kept a Treehouse lease no record describes"
+ pass "a Treehouse slot claim names the launched task, refuses when unclaimable, and a locked abort drops its claim and lease"
}
test_recorded_slot_blocks_reissue_before_get() {
From 73e3707fa8b7790b8fd5caa4abec2403e33a148c Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:25:37 +0200
Subject: [PATCH 03/10] no-mistakes(review): Require jq for leases; return
aborted lease by holder
---
bin/fm-backend.sh | 10 +--
bin/fm-spawn.sh | 37 ++++++----
bin/fm-wake-lib.sh | 11 +++
docs/configuration.md | 2 +-
tests/fixtures.sh | 11 ++-
tests/fm-bootstrap.test.sh | 7 +-
tests/fm-secondmate-safety.test.sh | 90 +++++++++++++++---------
tests/fm-spawn-pool-base-freshen.test.sh | 25 +++++++
8 files changed, 140 insertions(+), 53 deletions(-)
diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh
index 5d34e8bb150..619de501f37 100644
--- a/bin/fm-backend.sh
+++ b/bin/fm-backend.sh
@@ -297,9 +297,11 @@ fm_backend_validate_spawn() { #
# single owner of the per-backend dependency delta, so bootstrap follows the
# RESOLVED backend instead of demanding an inactive backend's tools. Each set is:
# - the session-provider CLI itself (tmux/herdr/zellij/orca/cmux);
-# - jq, for the JSON-emitting adapters (herdr, zellij, cmux) whose spawn/liveness
-# paths parse the backend's JSON output (see each adapter's
-# tool check, e.g. fm_backend_herdr_tool_check);
+# - jq, for the JSON-emitting adapters (herdr, zellij, cmux) whose
+# spawn/liveness paths parse the backend's JSON output (see each adapter's
+# tool check, e.g. fm_backend_herdr_tool_check), and for every treehouse
+# backend, whose slot-ownership proof reads Treehouse's JSON lease state
+# (bin/fm-wake-lib.sh);
# - the treehouse worktree provider for every session-provider-only backend
# (tmux, herdr, zellij, cmux); orca owns its own task worktree and terminal,
# so it drops both treehouse and any other backend's session CLI.
@@ -307,7 +309,7 @@ fm_backend_validate_spawn() { #
# 1 and prints nothing for an unknown backend.
fm_backend_required_tools() { #
case "$1" in
- tmux) printf '%s' 'tmux treehouse' ;;
+ tmux) printf '%s' 'tmux jq treehouse' ;;
herdr) printf '%s' 'herdr jq treehouse' ;;
zellij) printf '%s' 'zellij jq treehouse' ;;
cmux) printf '%s' 'cmux jq treehouse' ;;
diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh
index 19d3510086a..de90807632b 100755
--- a/bin/fm-spawn.sh
+++ b/bin/fm-spawn.sh
@@ -1116,15 +1116,25 @@ parse_orca_worktree_result() {
fi
}
-spawn_return_aborted_lease() { #
- local slot=$1 lease
- [ -n "$slot" ] && fm_treehouse_pool_slot "$PROJ_ABS" "$slot" || return 0
- lease=$(fm_treehouse_slot_lease "$slot") || return 0
- [ "${lease#*$'\t'}" = "$SPAWN_LEASE_HOLDER" ] || return 0
- if ! (cd "$PROJ_ABS" && treehouse return --force --if-lease-holder "$SPAWN_LEASE_HOLDER" "$slot") >/dev/null 2>&1; then
- echo "warning: could not return task $ID's aborted Treehouse lease on $slot; it stays reserved under holder $SPAWN_LEASE_HOLDER until it is returned by hand" >&2
+spawn_return_aborted_lease() {
+ local pool slots slot rc=0
+ if ! fm_treehouse_require_jq \
+ || ! pool=$(cd "$PROJ_ABS" && treehouse status --json 2>/dev/null) \
+ || ! slots=$(printf '%s\n' "$pool" | jq -r --arg holder "$SPAWN_LEASE_HOLDER" \
+ '.[] | select(.lease_holder == $holder) | .path'); then
+ echo "warning: could not read Treehouse's leases for $PROJ_ABS; any lease task $ID's aborted spawn took stays reserved under holder $SPAWN_LEASE_HOLDER until it is returned by hand" >&2
return 1
fi
+ while IFS= read -r slot; do
+ [ -n "$slot" ] || continue
+ if ! (cd "$PROJ_ABS" && treehouse return --force --if-lease-holder "$SPAWN_LEASE_HOLDER" "$slot") >/dev/null 2>&1; then
+ echo "warning: could not return task $ID's aborted Treehouse lease on $slot; it stays reserved under holder $SPAWN_LEASE_HOLDER until it is returned by hand" >&2
+ rc=1
+ fi
+ done <
done
}
+# Every Treehouse lease-state reader needs jq; without it they refuse loudly
+# rather than reading the pool as holding no slot or no lease.
+fm_treehouse_require_jq() {
+ command -v jq >/dev/null 2>&1 && return 0
+ echo "REFUSED: jq is required to read Treehouse lease state; install jq (bin/fm-bootstrap.sh reports it as MISSING) and retry" >&2
+ return 1
+}
+
# Read one native durable lease from the pool's own state. Unlike process
# occupancy, leased=true survives the worker exiting. Require one exact path
# entry and a nonempty identity; malformed or missing state proves nothing.
fm_treehouse_slot_lease() { #
local slot=$1 state
+ fm_treehouse_require_jq || return 1
if [ -d "$slot" ]; then
slot=$(CDPATH='' cd -- "$slot" && pwd -P) || return 1
fi
@@ -1360,6 +1369,7 @@ fm_treehouse_slot_lease() { #
# has no entry here and is never evidence of anything.
fm_treehouse_slot_registered() { #
local slot=$1 state
+ fm_treehouse_require_jq || return 1
if [ -d "$slot" ]; then
slot=$(CDPATH='' cd -- "$slot" && pwd -P) || return 1
fi
@@ -1379,6 +1389,7 @@ fm_treehouse_slot_registered() { #
fm_treehouse_require_reserved_records() { #
local project=$1 identity other_identity state meta recorded_project path field
identity=$(fm_treehouse_project_lock_path "$project") || return 1
+ fm_treehouse_require_jq || return 1
fm_treehouse_collect_states "$STATE" || return 1
for state in "${TREEHOUSE_OWNER_STATES[@]}"; do
for meta in "$state"/*.meta; do
diff --git a/docs/configuration.md b/docs/configuration.md
index e21c13f8799..a823e23ed78 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -538,7 +538,7 @@ This section is the single owner of that universal toolchain list; backend guide
In that list, no-mistakes runs the validation pipeline, gh-axi and chrome-devtools-axi cover GitHub and browser operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch.
Lavish is a presentation-only dependency for visual decisions and reports; nonvisual work can proceed with plain text when it is unavailable.
The per-backend delta is required only for the backend resolved from `FM_BACKEND`, then `config/backend`, then runtime auto-detection, then default `tmux`, so a home is never told to install a tool an inactive backend or feature would need.
-That delta is owned in code by `fm_backend_required_tools` in `bin/fm-backend.sh`: the resolved backend's own session-provider CLI (`tmux`, `herdr`, `zellij`, `orca`, or `cmux`), `jq` for the JSON-emitting adapters (`herdr`, `zellij`, `cmux`) whose spawn and liveness paths parse the backend's JSON output, and the `treehouse` worktree provider for every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`).
+That delta is owned in code by `fm_backend_required_tools` in `bin/fm-backend.sh`: the resolved backend's own session-provider CLI (`tmux`, `herdr`, `zellij`, `orca`, or `cmux`), `jq` for the JSON-emitting adapters (`herdr`, `zellij`, `cmux`) whose spawn and liveness paths parse the backend's JSON output and for every treehouse backend (`tmux` included), whose slot-ownership proof reads Treehouse's JSON lease state, and the `treehouse` worktree provider for every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`).
Backend tool availability uses the adapter's own executable resolver, so bootstrap and spawn agree on supported non-`PATH` locations such as cmux's bundled CLI.
An unknown resolved backend emits `BACKEND_INVALID` and blocks dispatch instead of silently dropping its dependency delta or falling back to tmux.
Orca provides both the task worktree and terminal endpoint (see "Runtime backend" above), so `backend=orca` requires only `orca` on top of the universal toolchain and skips both `treehouse` and every other backend's session CLI.
diff --git a/tests/fixtures.sh b/tests/fixtures.sh
index 7d27a7bad26..ec77a7b6a49 100755
--- a/tests/fixtures.sh
+++ b/tests/fixtures.sh
@@ -332,13 +332,22 @@ if [ "${1:-}" = return ]; then
mv "$state.tmp" "$state"
exit 0
fi
+if [ "${1:-}" = status ]; then
+ state="$(dirname "$(dirname "${FM_FAKE_PANE_PATH:-/}")")/treehouse-state.json"
+ if [ -f "$state" ]; then
+ jq '.worktrees' "$state"
+ else
+ printf '[]\n'
+ fi
+ exit 0
+fi
[ "${1:-}" = get ] || exit 0
holder=
while [ "$#" -gt 0 ]; do
case "$1" in --lease-holder) holder=$2; shift ;; esac
shift
done
-path=${FM_FAKE_PANE_PATH:-}
+path=${FM_FAKE_LEASE_PATH:-${FM_FAKE_PANE_PATH:-}}
if [ -n "$path" ]; then
state="$(dirname "$(dirname "$path")")/treehouse-state.json"
if [ -f "$state" ]; then
diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh
index d8cc824f0dd..6b10d3c29c7 100755
--- a/tests/fm-bootstrap.test.sh
+++ b/tests/fm-bootstrap.test.sh
@@ -656,7 +656,9 @@ test_unknown_backend_reports_invalid_configuration() {
test_json_backends_require_jq_not_tmux() {
local backend case_dir fakebin bash_env out
- # herdr/zellij/cmux parse their backend's JSON output, so jq is a genuine dep.
+ # herdr/zellij/cmux parse their backend's JSON output, and every treehouse
+ # backend (tmux included) reads Treehouse's JSON lease state, so jq is a
+ # genuine dep.
# jq lives in a system BASE_PATH dir on many hosts, so force it missing with a
# command()/jq() override (the same technique the git-required case uses) to keep
# the assertion host-independent.
@@ -687,11 +689,12 @@ SH
assert_contains "$out" "MISSING: jq" "backend=$backend must fail closed on missing jq"
assert_not_contains "$out" "MISSING: tmux" "backend=$backend must not demand tmux when jq is missing"
done <<'ROWS'
+tmux
herdr
zellij
cmux
ROWS
- pass "bootstrap: JSON-emitting backends require jq (their genuine dep), never tmux"
+ pass "bootstrap: JSON-emitting and treehouse backends require jq (their genuine dep), never an inactive tmux"
}
test_treehouse_lease_check_follows_resolved_backend() {
diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh
index 5efb6ee9840..ad283165ee6 100755
--- a/tests/fm-secondmate-safety.test.sh
+++ b/tests/fm-secondmate-safety.test.sh
@@ -2048,40 +2048,64 @@ EOF
}
test_home_seed_refuses_recorded_unleased_firstmate_slot() {
- local home acquired pool slot fakebin log lease err
- home="$TMP_ROOT/dash-recorded-slot-home"
- acquired="$TMP_ROOT/dash-recorded-slot-acquired"
- pool="$TMP_ROOT/dash-recorded-slot-pool"
- err="$TMP_ROOT/dash-recorded-slot.err"
- mkdir -p "$home/projects" "$home/data" "$home/state" "$pool/1/firstmate"
- home=$(cd "$home" && pwd -P)
- pool=$(cd "$pool" && pwd -P)
- slot="$pool/1/firstmate"
- fm_git_init_commit "$home/projects/alpha"
- fm_git_add_origin "$home/projects/alpha" "$TMP_ROOT/remotes/dash-recorded-slot-alpha.git"
- printf '%s\n' '- alpha [direct-PR] - alpha project (added 2026-06-22)' > "$home/data/projects.md"
- printf '{"worktrees":[{"name":"1","path":"%s"}]}\n' "$slot" > "$pool/treehouse-state.json"
- # A landed task on the Firstmate project itself was never torn down, and its
- # process-only slot is free for the next get again.
- printf 'project=%s\nworktree=%s\nkind=ship\n' "$ROOT" "$slot" > "$home/state/stale-crew.meta"
- fakebin=$(make_fake_tmux "$TMP_ROOT/dash-recorded-slot-fake")
- log="$TMP_ROOT/dash-recorded-slot-fake/tmux.log"
- lease="$TMP_ROOT/dash-recorded-slot-fake/lease"
+ local mode base home acquired pool slot fakebin log lease err bash_env
+ for mode in jq no-jq; do
+ base="$TMP_ROOT/dash-recorded-slot-$mode"
+ home="$base/home"
+ acquired="$base/acquired"
+ pool="$base/pool"
+ err="$base/seed.err"
+ mkdir -p "$home/projects" "$home/data" "$home/state" "$pool/1/firstmate"
+ home=$(cd "$home" && pwd -P)
+ pool=$(cd "$pool" && pwd -P)
+ slot="$pool/1/firstmate"
+ fm_git_init_commit "$home/projects/alpha"
+ fm_git_add_origin "$home/projects/alpha" "$TMP_ROOT/remotes/dash-recorded-slot-$mode-alpha.git"
+ printf '%s\n' '- alpha [direct-PR] - alpha project (added 2026-06-22)' > "$home/data/projects.md"
+ printf '{"worktrees":[{"name":"1","path":"%s"}]}\n' "$slot" > "$pool/treehouse-state.json"
+ # A landed task on the Firstmate project itself was never torn down, and its
+ # process-only slot is free for the next get again.
+ printf 'project=%s\nworktree=%s\nkind=ship\n' "$ROOT" "$slot" > "$home/state/stale-crew.meta"
+ fakebin=$(make_fake_tmux "$base/fake")
+ log="$base/fake/tmux.log"
+ lease="$base/fake/lease"
+ bash_env=/dev/null
+ if [ "$mode" = no-jq ]; then
+ # Without jq the lease state cannot be read; that must refuse, never pass.
+ bash_env="$base/no-jq.bash"
+ cat > "$bash_env" <<'SH'
+command() {
+ if [ "${1:-}" = -v ] && [ "${2:-}" = jq ]; then
+ return 1
+ fi
+ builtin command "$@"
+}
+jq() {
+ return 127
+}
+SH
+ fi
- if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TREEHOUSE_HOME="$acquired" FM_FAKE_TMUX_LOG="$log" \
- FM_FAKE_TREEHOUSE_LEASE_FILE="$lease" \
- FM_SECONDMATE_CHARTER='dash recorded scope' FM_SECONDMATE_SCOPE='dash recorded scope' \
- "$ROOT/bin/fm-home-seed.sh" dash - alpha >/dev/null 2>"$err"; then
- fail "home seeding leased a Firstmate slot while another task still records it"
- fi
- grep -F "$home/state/stale-crew.meta" "$err" >/dev/null \
- || fail "home seeding did not name the record still holding the slot: $(cat "$err")"
- grep -F 'treehouse get' "$log" >/dev/null && fail "home seeding requested a slot before refusing"
- [ ! -e "$lease" ] || fail "home seeding took a Treehouse lease despite the recorded slot"
- [ ! -e "$acquired" ] || fail "home seeding provisioned a home despite the recorded slot"
- [ ! -e "$home/data/secondmates.md" ] || fail "home seeding registered a secondmate despite the recorded slot"
- [ -e "$home/state/stale-crew.meta" ] || fail "home seeding removed the stale task record"
- pass "home seeding refuses to lease while a stale record names an unleased Firstmate slot"
+ if PATH="$fakebin:$PATH" BASH_ENV="$bash_env" FM_HOME="$home" FM_FAKE_TREEHOUSE_HOME="$acquired" \
+ FM_FAKE_TMUX_LOG="$log" FM_FAKE_TREEHOUSE_LEASE_FILE="$lease" \
+ FM_SECONDMATE_CHARTER='dash recorded scope' FM_SECONDMATE_SCOPE='dash recorded scope' \
+ "$ROOT/bin/fm-home-seed.sh" dash - alpha >/dev/null 2>"$err"; then
+ fail "$mode: home seeding leased a Firstmate slot while another task still records it"
+ fi
+ if [ "$mode" = jq ]; then
+ grep -F "$home/state/stale-crew.meta" "$err" >/dev/null \
+ || fail "$mode: home seeding did not name the record still holding the slot: $(cat "$err")"
+ else
+ grep -F 'jq is required' "$err" >/dev/null \
+ || fail "$mode: home seeding did not refuse on the missing jq: $(cat "$err")"
+ fi
+ grep -F 'treehouse get' "$log" >/dev/null && fail "$mode: home seeding requested a slot before refusing"
+ [ ! -e "$lease" ] || fail "$mode: home seeding took a Treehouse lease despite the recorded slot"
+ [ ! -e "$acquired" ] || fail "$mode: home seeding provisioned a home despite the recorded slot"
+ [ ! -e "$home/data/secondmates.md" ] || fail "$mode: home seeding registered a secondmate despite the recorded slot"
+ [ -e "$home/state/stale-crew.meta" ] || fail "$mode: home seeding removed the stale task record"
+ done
+ pass "home seeding refuses to lease while a stale record names an unleased Firstmate slot, and without jq"
}
test_secondmate_force_teardown_preserves_child_on_unproven_lock() {
diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh
index 9b6608a4939..0ad9786e90e 100755
--- a/tests/fm-spawn-pool-base-freshen.test.sh
+++ b/tests/fm-spawn-pool-base-freshen.test.sh
@@ -754,6 +754,30 @@ test_pool_slot_claim_follows_the_spawn_outcome() {
pass "a Treehouse slot claim names the launched task, refuses when unclaimable, and a locked abort drops its claim and lease"
}
+# The pane can keep reporting a stale path after Treehouse leased a different
+# slot. The abort must return the lease Treehouse recorded for this task, not
+# whatever slot the pane happened to name.
+test_aborted_spawn_returns_the_lease_treehouse_recorded() {
+ local rec id out status leased
+ id='pool-slot-stale-pane-r1'
+ rec=$(make_case slot-stale-pane "$id")
+ read_case_record "$rec"
+ lay_out_as_pool_slot
+ leased="$CASE_DIR/slots/2/project"
+ mkdir -p "$CASE_DIR/slots/2"
+ git -C "$PROJECT_DIR" worktree add --quiet --detach "$leased" "$INITIAL_SHA"
+ printf '{"worktrees":[{"name":"1","path":"%s"},{"name":"2","path":"%s"}]}\n' "$POOL_DIR" "$leased" \
+ > "$CASE_DIR/slots/treehouse-state.json"
+ out=$(FM_FAKE_LEASE_PATH="$leased" run_spawn "$id" --scout)
+ status=$?
+ [ "$status" -ne 0 ] || fail "spawn launched in a slot Treehouse did not lease to it"
+ [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "the aborted spawn published task metadata"
+ jq -e --arg path "$leased" '.worktrees[] | select(.path == $path) | .leased != true' \
+ "$CASE_DIR/slots/treehouse-state.json" >/dev/null \
+ || fail "the aborted spawn kept the lease Treehouse recorded for it: $out"
+ pass "an aborted spawn returns the lease Treehouse recorded for it, not the slot its pane reported"
+}
+
test_recorded_slot_blocks_reissue_before_get() {
local place rec id out status owner_home old_head old_state
for place in local registered metadata; do
@@ -796,6 +820,7 @@ test_recorded_slot_blocks_reissue_before_get() {
test_recorded_slot_blocks_reissue_before_get
test_remote_seeded_home_spawns_from_treehouse_pool
test_pool_slot_claim_follows_the_spawn_outcome
+test_aborted_spawn_returns_the_lease_treehouse_recorded
test_linked_spawning_home_rejects_primary_before_refresh
test_stale_pool_base_refreshes_before_branching
test_non_main_default_branch_refreshes_before_branching
From 4d70d491741773a7594d4ebd8aa9a9011bab753a Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 13 Sep 2026 16:02:14 +0200
Subject: [PATCH 04/10] no-mistakes(review): Enforce Treehouse 2.1 lease floor;
accept pre-identity leases
---
bin/fm-bootstrap.sh | 10 ++++---
bin/fm-install-treehouse.sh | 19 ++++++-------
bin/fm-wake-lib.sh | 30 +++++++++++++--------
docs/configuration.md | 1 +
tests/fm-bootstrap.test.sh | 34 ++++++++++++++----------
tests/fm-secondmate-harness.test.sh | 4 +--
tests/fm-secondmate-liveness.test.sh | 4 +--
tests/fm-secondmate-sync.test.sh | 4 +--
tests/fm-session-start.test.sh | 4 +--
tests/fm-spawn-pool-base-freshen.test.sh | 23 ++++++++++++++++
tests/fm-startup-memory-budget.test.sh | 4 +--
tests/fm-x-mode.test.sh | 4 +--
12 files changed, 92 insertions(+), 49 deletions(-)
diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh
index 31792fa37ba..0268eb240e9 100755
--- a/bin/fm-bootstrap.sh
+++ b/bin/fm-bootstrap.sh
@@ -53,8 +53,9 @@
# A TANGLE line means the firstmate primary checkout (FM_ROOT) is stranded
# on a feature branch instead of its default branch - a crewmate's work
# landed in the primary instead of its own worktree; restore it per the line.
-# treehouse is also MISSING when its installed version lacks
-# "treehouse get --lease" support.
+# treehouse is also MISSING when its installed version lacks the
+# durable lease identities of Treehouse v2.1.0 or newer
+# ("treehouse return --if-lease-id").
# no-mistakes is also MISSING when its installed version is older than
# 1.46.0 (structured pipeline attestation floor; see CONTRIBUTING.md).
# The AXI-family floor policy is owned beside GH_AXI_MIN and
@@ -925,8 +926,11 @@ NO_MISTAKES_MIN=1.46.0
GH_AXI_MIN=0.1.29
LAVISH_AXI_MIN=0.1.46
+# Slot ownership needs Treehouse v2.1.0's durable lease identities: lease_id,
+# `status --json`, and `return --if-lease-holder`/`--if-lease-id` all arrived
+# together, so `return --help` advertising --if-lease-id is the capability probe.
treehouse_supports_lease() {
- treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)'
+ treehouse return --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--if-lease-id([^[:alnum:]_-]|$)'
}
# Shared semantic-version floor for the tool gates below. A version string that
diff --git a/bin/fm-install-treehouse.sh b/bin/fm-install-treehouse.sh
index 9d181439c87..251730a2da3 100755
--- a/bin/fm-install-treehouse.sh
+++ b/bin/fm-install-treehouse.sh
@@ -9,12 +9,13 @@
# Usage:
# fm-install-treehouse.sh
#
-# Pins Treehouse v2.0.1, the version exercised by the local real-Herdr suite.
+# Pins Treehouse v2.3.0, the version exercised by the local real-Herdr suite;
+# Firstmate's slot ownership needs the lease identities of v2.1.0 or newer.
set -eu
-FM_TREEHOUSE_CI_VERSION=2.0.1
+FM_TREEHOUSE_CI_VERSION=2.3.0
FM_TREEHOUSE_CI_TAG="v${FM_TREEHOUSE_CI_VERSION}"
-# Bounded download ceiling (bytes). Official 2.0.1 archives are under 8 MiB.
+# Bounded download ceiling (bytes). Official 2.3.0 archives are under 8 MiB.
FM_TREEHOUSE_CI_MAX_BYTES=15000000
FM_TREEHOUSE_CI_REPO=kunchenguid/treehouse
@@ -30,19 +31,19 @@ arch=$(uname -m)
case "${os}-${arch}" in
Linux-x86_64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-linux-amd64.tar.gz
- SHA256=1d5a32751ab921670103fd201ddb2b91b47338cb13976f45642b827cf8976af2
+ SHA256=94fd2b2c20c35aac1ddc2941317890ad82c9916f5ccecbac4a50cda783eed10f
;;
Linux-aarch64|Linux-arm64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-linux-arm64.tar.gz
- SHA256=eaccc9c5b98125df8bd77425598eeecee66cb0371db4eb1cf75f0d813c18fab9
+ SHA256=408589ba72b58d5e942071ed863a83fd96566cfd1e514945daa59defde528bbb
;;
Darwin-arm64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-darwin-arm64.tar.gz
- SHA256=7ee5078f3d1f33c01196548797fce65408e459d53530b77d4ba56e074fa1c1a2
+ SHA256=1cb09bcfa830b4eec5e54beeaa71589adb9c5d828573dda0f5150e2d80cf13d5
;;
Darwin-x86_64)
ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-darwin-amd64.tar.gz
- SHA256=1cf44580a5837f995e1d3bb74f4fbd3112b642acd20406087d9735a8106112fd
+ SHA256=349afcc13c2beb20d846eb560a11b30e1a5cab8e2dfb22988a36aa7f213b5881
;;
*)
die "unsupported platform ${os}-${arch}; official Treehouse assets are linux/darwin amd64 and arm64"
@@ -68,7 +69,7 @@ fi
[ "$ACTUAL_SHA256" = "$SHA256" ] || die "checksum mismatch for $ARCHIVE (expected $SHA256, got $ACTUAL_SHA256)"
tar -xzf "$TMP/$ARCHIVE" -C "$TMP"
-# Archive layout: a single `treehouse` binary at the archive root (verified for v2.0.1).
+# Archive layout: a single `treehouse` binary at the archive root (verified for v2.3.0).
if [ -f "$TMP/treehouse" ]; then
BIN="$TMP/treehouse"
elif [ -f "$TMP/treehouse-v${FM_TREEHOUSE_CI_VERSION}/treehouse" ]; then
@@ -82,7 +83,7 @@ mkdir -p "$DESTINATION"
install -m 0755 "$BIN" "$DESTINATION/treehouse"
installed_version=$("$DESTINATION/treehouse" --version 2>/dev/null | tr -d '[:space:]')
-# treehouse prints "v2.0.1" (leading v) on --version.
+# treehouse prints "v2.3.0" (leading v) on --version.
case "$installed_version" in
"v${FM_TREEHOUSE_CI_VERSION}"|"${FM_TREEHOUSE_CI_VERSION}") ;;
*)
diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh
index c7cb42c8bf1..7369e1ea3fd 100755
--- a/bin/fm-wake-lib.sh
+++ b/bin/fm-wake-lib.sh
@@ -1364,10 +1364,13 @@ fm_treehouse_slot_lease() { #
}
# Whether a path is structurally a Treehouse-managed slot at all - it appears
-# in the pool's own state file - independent of whether it currently carries a
-# lease. An ordinary linked worktree that merely shares a project's identity
-# has no entry here and is never evidence of anything.
-fm_treehouse_slot_registered() { #
+# exactly once in the pool's own state file - and, if so, whether that entry is
+# durably leased: prints true or false, and nothing for a path that is no slot.
+# An ordinary linked worktree that merely shares a project's identity has no
+# entry here and is never evidence of anything. Only reservation keeps get from
+# reissuing a slot, so a lease taken before Treehouse had lease identities, with
+# no lease_id, still counts as reserved here.
+fm_treehouse_slot_reserved() { #
local slot=$1 state
fm_treehouse_require_jq || return 1
if [ -d "$slot" ]; then
@@ -1375,7 +1378,9 @@ fm_treehouse_slot_registered() { #
fi
state="$(dirname "$(dirname "$slot")")/treehouse-state.json"
[ -f "$state" ] && [ ! -L "$state" ] && [ -r "$state" ] || return 1
- jq -e --arg path "$slot" '[.worktrees[] | select(.path == $path)] | length == 1' "$state" >/dev/null
+ jq -r --arg path "$slot" '
+ [.worktrees[] | select(.path == $path)] | select(length == 1) | .[0].leased == true
+ ' "$state"
}
# Before ANY get can reset/reissue a legacy slot, every still-recorded copy in
@@ -1383,7 +1388,7 @@ fm_treehouse_slot_registered() { #
# must be torn down or reconciled first; never upgrade their lease by guessing
# the occupant. The project lock stays held from this scan through publication.
# Identity match alone only says a record could share this project's pool -
-# fm_treehouse_slot_registered still has to prove the recorded path is actually
+# fm_treehouse_slot_reserved still has to prove the recorded path is actually
# a managed slot before an absent lease means anything; an ordinary linked
# worktree recorded incidentally against the same project is never a slot.
fm_treehouse_require_reserved_records() { #
@@ -1402,11 +1407,12 @@ fm_treehouse_require_reserved_records() { #
[ -n "$path" ] || continue
# Matching origin also covers separate clones sharing Treehouse's pool.
[ "$identity" = "$other_identity" ] || fm_treehouse_pool_slot "$project" "$path" || continue
- fm_treehouse_slot_registered "$path" || continue
- if ! fm_treehouse_slot_lease "$path" >/dev/null 2>&1; then
- echo "REFUSED: $meta still records $field=$path without a durable Treehouse lease; allocation could re-issue that task's slot. Tear down or reconcile that record before spawning; no slot was requested." >&2
- return 1
- fi
+ case "$(fm_treehouse_slot_reserved "$path")" in
+ false)
+ echo "REFUSED: $meta still records $field=$path without a durable Treehouse lease; allocation could re-issue that task's slot. Tear down or reconcile that record before spawning; no slot was requested." >&2
+ return 1
+ ;;
+ esac
done
done
done
@@ -1424,6 +1430,8 @@ fm_treehouse_lease_holder() { #
# dirties the work. New claims require the native lease's exact holder and store
# its lease_id; teardown validates both before cleanup and conditions return on
# that identity. Legacy claims have no lease_id and keep record-scan protection.
+# Lease identities need Treehouse v2.1.0 or newer, the floor bin/fm-bootstrap.sh
+# enforces.
# Unleased legacy metadata blocks new allocation until teardown/reconciliation.
fm_treehouse_slot_owner_marker() { #
local worktree=$1 slot
diff --git a/docs/configuration.md b/docs/configuration.md
index a823e23ed78..c5868020d8a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -543,6 +543,7 @@ Backend tool availability uses the adapter's own executable resolver, so bootstr
An unknown resolved backend emits `BACKEND_INVALID` and blocks dispatch instead of silently dropping its dependency delta or falling back to tmux.
Orca provides both the task worktree and terminal endpoint (see "Runtime backend" above), so `backend=orca` requires only `orca` on top of the universal toolchain and skips both `treehouse` and every other backend's session CLI.
A herdr, zellij, or cmux home is therefore never told `tmux` is missing, and the `treehouse` durable-lease upgrade check runs only for the backends that actually use treehouse.
+That check enforces Treehouse v2.1.0 or newer: slot ownership relies on its durable lease identities (`lease_id`, `status --json`, and `return --if-lease-holder`/`--if-lease-id`), so an older treehouse is reported as `MISSING: treehouse`.
When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispatch profile validation.
When Relay is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim.
`tasks-axi` and `quota-axi` are essential bootstrap tools in every profile.
diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh
index 6b10d3c29c7..709a7eab881 100755
--- a/tests/fm-bootstrap.test.sh
+++ b/tests/fm-bootstrap.test.sh
@@ -7,8 +7,9 @@
# 'MISSING: tasks-axi (install: ...)', 'MISSING: quota-axi (install: ...)',
# 'MISSING: gh-axi (install: ...)', 'PRESENTATION_UNAVAILABLE: lavish-axi ...', and
# 'BOOTSTRAP_INFO: ...' lines, so those contracts are pinned verbatim. The cases
-# are table-driven over the inputs that vary: whether `treehouse get --help`
-# advertises --lease, which (if any) tasks-axi version is on PATH, whether
+# are table-driven over the inputs that vary: whether `treehouse return --help`
+# advertises Treehouse v2.1.0's lease identities, which (if any) tasks-axi
+# version is on PATH, whether
# tasks-axi update advertises --archive-body, whether its mv help advertises
# multi-ID moves, whether quota-axi is on PATH,
# whether the local backend config opts out of tasks-axi backlog mutations,
@@ -40,7 +41,9 @@ unset TMUX TMUX_PANE HERDR_ENV HERDR_PANE_ID HERDR_SESSION HERDR_SOCKET_PATH \
CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_SOCKET_PATH CMUX_TAB_ID CMUX_PANEL_ID 2>/dev/null || true
# A fake toolchain where every required tool is present and gh is authenticated.
-# treehouse's `get --help` advertises --lease only when FM_FAKE_TREEHOUSE_LEASE_HELP=1.
+# treehouse's `get --help` always advertises --lease, as every treehouse since
+# 1.8 does; its `return --help` advertises Treehouse v2.1.0's lease-identity
+# flags only when FM_FAKE_TREEHOUSE_LEASE_HELP=1.
make_fake_toolchain() {
local dir=$1 fakebin
fakebin=$(fm_fakebin "$dir")
@@ -65,14 +68,17 @@ SH
chmod +x "$fakebin/gh"
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
-if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
- if [ "${FM_FAKE_TREEHOUSE_LEASE_HELP:-}" = 1 ]; then
+case "${1:-} ${2:-}" in
+ 'get --help')
printf '%s\n' 'Usage: treehouse get [--lease] [--lease-holder ]'
- else
- printf '%s\n' 'Usage: treehouse get'
- fi
- exit 0
-fi
+ ;;
+ 'return --help')
+ printf '%s\n' 'Usage: treehouse return [path] [flags]' ' --force'
+ if [ "${FM_FAKE_TREEHOUSE_LEASE_HELP:-}" = 1 ]; then
+ printf '%s\n' ' --if-lease-holder string' ' --if-lease-id string'
+ fi
+ ;;
+esac
exit 0
SH
chmod +x "$fakebin/treehouse"
@@ -304,8 +310,8 @@ test_bootstrap_reporting() {
;;
esac
done <<'ROWS'
-treehouse --lease support is accepted silently^1^0.2.4^1^manual^empty^^
-treehouse without --lease reports an upgrade, gh auth is fine^0^0.2.4^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH
+treehouse lease identities are accepted silently^1^0.2.4^1^manual^empty^^
+treehouse with --lease but no lease identities reports an upgrade, gh auth is fine^0^0.2.4^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH
compatible tasks-axi is silent by default^1^0.2.4^1^-^empty^^
missing tasks-axi is required by default^1^-^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^
incompatible tasks-axi is required by default^1^0.1.0^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^
@@ -699,7 +705,7 @@ ROWS
test_treehouse_lease_check_follows_resolved_backend() {
local case_dir fakebin out
- # A treehouse that lacks durable --lease support is only a problem for a backend
+ # A treehouse that lacks durable lease identities is only a problem for a backend
# that actually uses treehouse. Orca owns its own worktrees, so an old treehouse
# must NOT trip MISSING: treehouse under backend=orca...
case_dir="$TMP_ROOT/orca-old-treehouse"
@@ -709,7 +715,7 @@ test_treehouse_lease_check_follows_resolved_backend() {
fakebin=$(make_fake_toolchain "$case_dir")
rm -f "$fakebin/tmux"
fm_fake_exit0 "$fakebin" orca
- # FM_FAKE_TREEHOUSE_LEASE_HELP unset: the fake treehouse advertises NO --lease.
+ # FM_FAKE_TREEHOUSE_LEASE_HELP unset: the fake treehouse advertises NO lease identities.
out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \
"$ROOT/bin/fm-bootstrap.sh")
[ -z "$out" ] || fail "backend=orca must not require treehouse (even lease-less) or tmux, got: $out"
diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh
index 4b1b89b3e67..a2f43c3c377 100755
--- a/tests/fm-secondmate-harness.test.sh
+++ b/tests/fm-secondmate-harness.test.sh
@@ -1117,8 +1117,8 @@ SH
chmod +x "$fakebin/gh"
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
-if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
- printf '%s\n' 'Usage: treehouse get [--lease]'
+if [ "${1:-}" = return ] && [ "${2:-}" = --help ]; then
+ printf '%s\n' ' --if-lease-id string Return only if the current lease has this identity'
fi
exit 0
SH
diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh
index 72aa0697fd8..11594b7a7b7 100755
--- a/tests/fm-secondmate-liveness.test.sh
+++ b/tests/fm-secondmate-liveness.test.sh
@@ -224,8 +224,8 @@ SH
chmod +x "$fakebin/gh"
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
-if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
- printf '%s\n' 'Usage: treehouse get [--lease]'
+if [ "${1:-}" = return ] && [ "${2:-}" = --help ]; then
+ printf '%s\n' ' --if-lease-id string Return only if the current lease has this identity'
fi
exit 0
SH
diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh
index 1e5d2290f32..98ecccc569d 100755
--- a/tests/fm-secondmate-sync.test.sh
+++ b/tests/fm-secondmate-sync.test.sh
@@ -361,8 +361,8 @@ SH
chmod +x "$fakebin/gh"
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
-if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
- printf '%s\n' 'Usage: treehouse get [--lease]'
+if [ "${1:-}" = return ] && [ "${2:-}" = --help ]; then
+ printf '%s\n' ' --if-lease-id string Return only if the current lease has this identity'
fi
exit 0
SH
diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh
index b14639b3dde..a6fa324ada4 100755
--- a/tests/fm-session-start.test.sh
+++ b/tests/fm-session-start.test.sh
@@ -89,8 +89,8 @@ SH
chmod +x "$fakebin/gh"
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
-if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
- printf '%s\n' 'Usage: treehouse get [--lease]'
+if [ "${1:-}" = return ] && [ "${2:-}" = --help ]; then
+ printf '%s\n' ' --if-lease-id string Return only if the current lease has this identity'
exit 0
fi
exit 0
diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh
index 0ad9786e90e..9321a7a6b56 100755
--- a/tests/fm-spawn-pool-base-freshen.test.sh
+++ b/tests/fm-spawn-pool-base-freshen.test.sh
@@ -778,6 +778,28 @@ test_aborted_spawn_returns_the_lease_treehouse_recorded() {
pass "an aborted spawn returns the lease Treehouse recorded for it, not the slot its pane reported"
}
+# A lease taken before Treehouse had lease identities carries no lease_id but is
+# still durably reserved, so a record naming that slot must not block allocation.
+test_pre_identity_lease_counts_as_reserved() {
+ local rec id out status reserved
+ id='pool-slot-pre-identity-r1'
+ rec=$(make_case slot-pre-identity "$id")
+ read_case_record "$rec"
+ lay_out_as_pool_slot
+ reserved="$CASE_DIR/slots/2/project"
+ mkdir -p "$CASE_DIR/slots/2"
+ git -C "$PROJECT_DIR" worktree add --quiet --detach "$reserved" "$INITIAL_SHA"
+ printf '{"worktrees":[{"name":"1","path":"%s"},{"name":"2","path":"%s","leased":true,"lease_holder":"mate"}]}\n' \
+ "$POOL_DIR" "$reserved" > "$CASE_DIR/slots/treehouse-state.json"
+ fm_write_meta "$HOME_DIR/state/mate-task.meta" "project=$PROJECT_DIR" "worktree=$reserved" "kind=ship"
+ out=$(run_spawn "$id" --scout)
+ status=$?
+ expect_code 0 "$status" "a pre-identity lease on another recorded slot blocked allocation"$'\n'"$out"
+ assert_grep "worktree=$POOL_DIR" "$HOME_DIR/state/$id.meta" \
+ "spawn did not publish the slot it was allocated"
+ pass "a lease taken before Treehouse lease identities still counts as reserved"
+}
+
test_recorded_slot_blocks_reissue_before_get() {
local place rec id out status owner_home old_head old_state
for place in local registered metadata; do
@@ -818,6 +840,7 @@ test_recorded_slot_blocks_reissue_before_get() {
}
test_recorded_slot_blocks_reissue_before_get
+test_pre_identity_lease_counts_as_reserved
test_remote_seeded_home_spawns_from_treehouse_pool
test_pool_slot_claim_follows_the_spawn_outcome
test_aborted_spawn_returns_the_lease_treehouse_recorded
diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh
index fe5a5439f62..24a4486ef47 100755
--- a/tests/fm-startup-memory-budget.test.sh
+++ b/tests/fm-startup-memory-budget.test.sh
@@ -37,8 +37,8 @@ exit 0
SH
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
-if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
- printf '%s\n' 'Usage: treehouse get [--lease]'
+if [ "${1:-}" = return ] && [ "${2:-}" = --help ]; then
+ printf '%s\n' ' --if-lease-id string Return only if the current lease has this identity'
fi
SH
cat > "$fakebin/no-mistakes" <<'SH'
diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh
index 9f45252bc70..bed9b9974a8 100755
--- a/tests/fm-x-mode.test.sh
+++ b/tests/fm-x-mode.test.sh
@@ -808,8 +808,8 @@ SH
chmod +x "$fakebin/gh"
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
-if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then
- printf '%s\n' 'Usage: treehouse get [--lease] [--lease-holder ]'
+if [ "${1:-}" = return ] && [ "${2:-}" = --help ]; then
+ printf '%s\n' ' --if-lease-id string Return only if the current lease has this identity'
exit 0
fi
exit 0
From 6b643e5903743a92e5c7da64ea47ff6c216ae8bb Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 13 Sep 2026 16:21:55 +0200
Subject: [PATCH 05/10] no-mistakes(test): Pass spawning FM_HOME to herdr e2e
teardown calls
---
tests/fm-backend-herdr-workspace-per-home-e2e.test.sh | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh b/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh
index 6f07798aa48..94cb0dab872 100755
--- a/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh
+++ b/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh
@@ -225,7 +225,9 @@ pass "real herdr E2E: list_live from the secondmate's own context sees only task
# --- 5. teardown closes the RIGHT tab, and no other ------------------------
TD1_OUT="$TMP_ROOT/td1.out"
-FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$PRIMARY_HOME/state" FM_DATA_OVERRIDE="$PRIMARY_HOME/data" \
+# Like every real caller, teardown runs with the FM_HOME the task was spawned
+# from: slot ownership binds the task id to that canonical home.
+FM_HOME="$PRIMARY_HOME" FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$PRIMARY_HOME/state" FM_DATA_OVERRIDE="$PRIMARY_HOME/data" \
FM_CONFIG_OVERRIDE="$PRIMARY_HOME/config" \
"$ROOT/bin/fm-teardown.sh" cm1 >"$TD1_OUT" 2>&1
rc=$?
@@ -244,7 +246,7 @@ WT1=
pass "real herdr E2E: tearing down cm1 closes only its own tab - the secondmate's and cm2's tabs survive untouched"
TD2_OUT="$TMP_ROOT/td2.out"
-FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$SM_HOME/state" FM_DATA_OVERRIDE="$SM_HOME/data" \
+FM_HOME="$SM_HOME" FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$SM_HOME/state" FM_DATA_OVERRIDE="$SM_HOME/data" \
FM_CONFIG_OVERRIDE="$SM_HOME/config" \
"$ROOT/bin/fm-teardown.sh" cm2 >"$TD2_OUT" 2>&1
rc=$?
From de76cdfd55d98d3db8dd8dae7f5386ef34a90b37 Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 13 Sep 2026 16:33:26 +0200
Subject: [PATCH 06/10] no-mistakes(document): Point bootstrap diagnostics at
the Treehouse lease-identity floor
---
.agents/skills/bootstrap-diagnostics/SKILL.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md
index 1d49f4b8312..ff8e1d375ea 100644
--- a/.agents/skills/bootstrap-diagnostics/SKILL.md
+++ b/.agents/skills/bootstrap-diagnostics/SKILL.md
@@ -17,7 +17,7 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then
When any diagnostic needs captain attention, report the plain consequence and requested action using `AGENTS.md` section 9's captain-facing translation contract; do not name the diagnostic label unless the captain needs to paste it into a command or issue.
- `MISSING: (install: )` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install `.
- For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request.
+ For `treehouse`, this also covers an installed version below the durable-lease-identity floor that [`docs/configuration.md`](../../../docs/configuration.md#toolchain) states; treat it as an upgrade request.
For `no-mistakes`, this also covers an installed version older than 1.46.0, because this repo's PR gate requires structured pipeline attestation that older builds do not write.
For essential axi-family tools - `gh-axi`, `tasks-axi`, `quota-axi` - an installed version below its floor is a plain upgrade request; [`bin/fm-bootstrap.sh`](../../../bin/fm-bootstrap.sh) owns the floor policy, and never argue the floor down to whatever the home happens to have installed.
For `tasks-axi`, this additionally covers an installed build that fails the separate feature probe (`bin/fm-tasks-axi-lib.sh` owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report.
From 06ce528bce3d0364efc65fded1c3a413b3cac07a Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:15:56 +0200
Subject: [PATCH 07/10] test: pass the spawning FM_HOME to the
launcher-workspace e2e teardown
Slot ownership now binds a task id to its canonical home, so teardown must run
with the FM_HOME the task was spawned from. The launcher-workspace e2e spawns
dupC from the primary home but tore it down with only FM_ROOT_OVERRIDE set, so
teardown resolved the repository root as its home and refused the slot's own
claim as another task's.
---
tests/fm-backend-herdr-launcher-workspace-e2e.test.sh | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh
index e961550d839..48897604168 100755
--- a/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh
+++ b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh
@@ -425,7 +425,9 @@ pass "real herdr E2E: a --secondmate launch still stands up that secondmate's ow
# --- 8. teardown closes only the worker's own pane --------------------------
-FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$PRIMARY_HOME/state" FM_DATA_OVERRIDE="$PRIMARY_HOME/data" \
+# Like every real caller, teardown runs with the FM_HOME the task was spawned
+# from: slot ownership binds the task id to that canonical home.
+FM_HOME="$PRIMARY_HOME" FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$PRIMARY_HOME/state" FM_DATA_OVERRIDE="$PRIMARY_HOME/data" \
FM_CONFIG_OVERRIDE="$PRIMARY_HOME/config" \
"$ROOT/bin/fm-teardown.sh" dupC >"$TMP_ROOT/teardown.out" 2>&1
status=$?
From 6e33d1b26aa666188d1a2ea44939f5d6634792c1 Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 20 Sep 2026 19:51:16 +0200
Subject: [PATCH 08/10] test: count a pane-death removal in the concurrent
abort cleanup order
The task pane of an aborted spawn is now a lone idle shell, so cleanup removes it
through Herdr's pane-death path, which logs no pane.close. The order check only
looked for pane.close on the task pane and saw no close at all. Count any
pane.close inside the task's own projected workspace, the seeded pane's prune
included, so the create-then-close serialization is still asserted for both
aborts.
---
tests/fm-backend-herdr-presentation-e2e.test.sh | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh
index 4aef0afc349..9829f2f78d9 100755
--- a/tests/fm-backend-herdr-presentation-e2e.test.sh
+++ b/tests/fm-backend-herdr-presentation-e2e.test.sh
@@ -860,12 +860,20 @@ grep -F "did not enter an isolated worktree" "$TMP_ROOT/abort-b.err" >/dev/null
|| fail "post-create abort fixture B did not reach the armed validation failure"
ABORT_A_PANE=$(cat "$POST_CREATE_ABORT_CONTROL/abort-a/task-pane")
ABORT_B_PANE=$(cat "$POST_CREATE_ABORT_CONTROL/abort-b/task-pane")
-ABORT_SEQUENCE=$(sed -n "$((ABORT_FOCUS_START + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' -v a="$ABORT_A_PANE" -v b="$ABORT_B_PANE" '
+# A task pane that is a lone idle shell is removed through Herdr's pane-death
+# path, which logs no pane.close (see assert_cleanup_focus_preserved), so each
+# task's close is any pane.close inside its own projected workspace, the seeded
+# pane's prune included, and consecutive closes of one task collapse into one.
+ABORT_A_WS=$(cat "$POST_CREATE_ABORT_CONTROL/abort-a/workspace")
+ABORT_B_WS=$(cat "$POST_CREATE_ABORT_CONTROL/abort-b/workspace")
+[ -n "$ABORT_A_WS" ] && [ -n "$ABORT_B_WS" ] || fail "post-create abort fixtures did not record their projected workspaces"
+ABORT_SEQUENCE=$(sed -n "$((ABORT_FOCUS_START + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' \
+ -v a="$ABORT_A_PANE" -v b="$ABORT_B_PANE" -v wa="$ABORT_A_WS" -v wb="$ABORT_B_WS" '
$1 == "workspace-create" && $4 ~ /^└ abort-a · p:/ { print "create-a" }
$1 == "workspace-create" && $4 ~ /^└ abort-b · p:/ { print "create-b" }
- $1 == "pane-close" && $4 == a { print "close-a" }
- $1 == "pane-close" && $4 == b { print "close-b" }
-')
+ $1 == "pane-close" && ($4 == a || index($4, wa ":") == 1) { print "close-a" }
+ $1 == "pane-close" && ($4 == b || index($4, wb ":") == 1) { print "close-b" }
+' | uniq)
case "$ABORT_SEQUENCE" in
$'create-a\nclose-a\ncreate-b\nclose-b'|$'create-b\nclose-b\ncreate-a\nclose-a') ;;
*) fail "concurrent post-create abort cleanup interleaved outside the presentation lock: $ABORT_SEQUENCE" ;;
From 5314adf3a882c4fc209f81761217493ae2ad95f3 Mon Sep 17 00:00:00 2001
From: MLA82 <212341412+MLA82@users.noreply.github.com>
Date: Sun, 20 Sep 2026 20:38:21 +0200
Subject: [PATCH 09/10] fix(bin): enter a leased Treehouse slot in a child
shell
The pane's own shell used to cd into the leased slot, so teardown's reap of
processes under the slot ended the endpoint shell itself. Herdr then removed
the pane and its workspace on its own, ahead of the focus-preserving close, and
the active workspace jumped. Run the worker in a child shell inside the slot,
as the interactive treehouse get subshell did, so the endpoint shell stays in
the project.
---
bin/fm-spawn.sh | 6 +++++-
tests/fixtures.sh | 2 +-
tests/fm-tangle-guard.test.sh | 4 ++--
3 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh
index de90807632b..776a75fa104 100755
--- a/bin/fm-spawn.sh
+++ b/bin/fm-spawn.sh
@@ -3850,7 +3850,11 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
SPAWN_LEASE_HOLDER=$(fm_treehouse_lease_holder "$ID" "$FM_HOME") || exit 1
# Keep acquisition in the task shell so Treehouse uses the same pool config
# and environment as before. A durable lease survives this shell's exit.
- spawn_send_text_line "$WT_TARGET" "fm_slot=\$(treehouse get --lease --lease-holder $(shell_quote "$SPAWN_LEASE_HOLDER")) && [ -n \"\$fm_slot\" ] && cd -- \"\$fm_slot\""
+ # The worker then runs in a child shell inside the slot, as the interactive
+ # `treehouse get` subshell did: the endpoint's own shell stays in the project,
+ # so teardown's reap of processes under the slot never ends the endpoint
+ # itself before its own locked close.
+ spawn_send_text_line "$WT_TARGET" "fm_slot=\$(treehouse get --lease --lease-holder $(shell_quote "$SPAWN_LEASE_HOLDER")) && [ -n \"\$fm_slot\" ] && ( cd -- \"\$fm_slot\" && exec \"\${SHELL:-/bin/sh}\" )"
# Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree.
# Target the stable window id, not the name: if the name is ever lost (e.g. an
diff --git a/tests/fixtures.sh b/tests/fixtures.sh
index ec77a7b6a49..240545e3a4c 100755
--- a/tests/fixtures.sh
+++ b/tests/fixtures.sh
@@ -123,7 +123,7 @@ case "${1:-}" in
# Model only the shell's lease-acquire command; never execute a worker.
for a in "$@"; do
case "$a" in
- 'fm_slot=$(treehouse get --lease '* ) bash -c "$a" || true ;;
+ 'fm_slot=$(treehouse get --lease '* ) SHELL=true bash -c "$a"
Date: Sun, 20 Sep 2026 21:01:17 +0200
Subject: [PATCH 10/10] test: drop the slot claim when a restart fixture
returns a slot by hand
A claim naming another task is never overwritten by a later spawn, so the
restart fixtures, which return their leased slots with treehouse directly
instead of through teardown, left a stale claim behind. The next fixture then
received that slot and its spawn refused to claim it. Return the lease and drop
the claim together, as reconciling such a slot by hand does.
---
.../fm-backend-herdr-presentation-e2e.test.sh | 27 ++++++++++++-------
1 file changed, 18 insertions(+), 9 deletions(-)
diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh
index 9829f2f78d9..bdaf44950d9 100755
--- a/tests/fm-backend-herdr-presentation-e2e.test.sh
+++ b/tests/fm-backend-herdr-presentation-e2e.test.sh
@@ -374,6 +374,15 @@ assert_cleanup_focus_preserved() { #
fi
}
+# The fixture's stand-in for reconciling a slot by hand: return the lease and
+# drop the claim naming the task that held it. A claim naming another task is
+# never overwritten by a later spawn, so a slot returned outside teardown must
+# not keep its claim.
+return_fixture_slot() { #
+ "$REAL_TREEHOUSE" return --force "$1" >/dev/null 2>&1 || true
+ rm -f "$(dirname "$1")/.fm-slot-owner"
+}
+
remember_meta_worktree() { #
local wt
wt=$(grep '^worktree=' "$1" | cut -d= -f2-)
@@ -1254,7 +1263,7 @@ for RESTART_ID in fm-hibit-resume-r1 wheelhouse-healing-r1; do
[ "$NEW_RESTART_PANE" != "$PRIOR_RESTART_PANE" ] \
|| fail "$RESTART_ID repeated reclaim reused the prior husk pane"
if [ "$PRIOR_RESTART_WT" != "$NEW_RESTART_WT" ]; then
- "$REAL_TREEHOUSE" return --force "$PRIOR_RESTART_WT" >/dev/null 2>&1 || true
+ return_fixture_slot "$PRIOR_RESTART_WT"
fi
fi
@@ -1262,8 +1271,8 @@ for RESTART_ID in fm-hibit-resume-r1 wheelhouse-healing-r1; do
|| fail "$RESTART_ID teardown after reclaim failed: $(cat "$TMP_ROOT/$RESTART_ID-teardown.err")"
[ ! -e "$HOME_DIR/state/$RESTART_ID.herdr-presentation" ] \
|| fail "$RESTART_ID exact reclaimed teardown did not retire its journal"
- "$REAL_TREEHOUSE" return --force "$OLD_RESTART_WT" >/dev/null 2>&1 || true
- "$REAL_TREEHOUSE" return --force "$NEW_RESTART_WT" >/dev/null 2>&1 || true
+ return_fixture_slot "$OLD_RESTART_WT"
+ return_fixture_slot "$NEW_RESTART_WT"
done
pass "real Herdr lab: Hi Bit and Wheelhouse-style same-identity restarts reclaim one nested space with exact focus and idempotence"
@@ -1298,8 +1307,8 @@ CROSS_NEW_PANE=$(grep '^herdr_pane_id=' "$CROSS_RESTART_META" | cut -d= -f2-)
|| fail "cross-home reclaim changed the secondmate child's presentation label"
teardown_task "$CROSS_RESTART_ID" "$SECOND_HOME_A" > "$TMP_ROOT/cross-restart-teardown.out" 2> "$TMP_ROOT/cross-restart-teardown.err" \
|| fail "cross-home reclaimed teardown failed: $(cat "$TMP_ROOT/cross-restart-teardown.err")"
-"$REAL_TREEHOUSE" return --force "$CROSS_OLD_WT" >/dev/null 2>&1 || true
-"$REAL_TREEHOUSE" return --force "$CROSS_NEW_WT" >/dev/null 2>&1 || true
+return_fixture_slot "$CROSS_OLD_WT"
+return_fixture_slot "$CROSS_NEW_WT"
pass "real Herdr lab: secondmate restart binding and reclaim stay isolated to the exact child home and parent"
# Two homes recovering concurrently serialize on the named session lock and
@@ -1351,10 +1360,10 @@ teardown_task "$PRIMARY_WAVE_ID" "$HOME_DIR" > "$TMP_ROOT/primary-wave-teardown.
|| fail "concurrent primary recovery teardown failed: $(cat "$TMP_ROOT/primary-wave-teardown.err")"
teardown_task "$BRAVO_WAVE_ID" "$SECOND_HOME_B" > "$TMP_ROOT/bravo-wave-teardown.out" 2> "$TMP_ROOT/bravo-wave-teardown.err" \
|| fail "concurrent secondmate recovery teardown failed: $(cat "$TMP_ROOT/bravo-wave-teardown.err")"
-"$REAL_TREEHOUSE" return --force "$PRIMARY_WAVE_OLD_WT" >/dev/null 2>&1 || true
-"$REAL_TREEHOUSE" return --force "$BRAVO_WAVE_OLD_WT" >/dev/null 2>&1 || true
-"$REAL_TREEHOUSE" return --force "$PRIMARY_WAVE_NEW_WT" >/dev/null 2>&1 || true
-"$REAL_TREEHOUSE" return --force "$BRAVO_WAVE_NEW_WT" >/dev/null 2>&1 || true
+return_fixture_slot "$PRIMARY_WAVE_OLD_WT"
+return_fixture_slot "$BRAVO_WAVE_OLD_WT"
+return_fixture_slot "$PRIMARY_WAVE_NEW_WT"
+return_fixture_slot "$BRAVO_WAVE_NEW_WT"
pass "real Herdr lab: concurrent cross-home recoveries replace exact husks under one session lock with no focus drift"
# Seed a legacy old-format primary projection and a flat secondmate tab; correction must not migrate them.