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. 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-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-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-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-spawn.sh b/bin/fm-spawn.sh index b1b8608531d..776a75fa104 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -131,14 +131,11 @@ # 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. 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; @@ -1083,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= @@ -1118,6 +1116,27 @@ parse_orca_worktree_result() { fi } +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 <&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 @@ -3818,7 +3846,15 @@ 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. + # 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 @@ -3879,18 +3915,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..7369e1ea3fd 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,207 @@ 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: 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 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 + } + 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) || { + echo "REFUSED: registered local Firstmate home is unavailable: ${homes[$i]}" >&2 + return 1 + } + i=$((i + 1)) + known=0 + for existing in "${visited[@]+"${visited[@]}"}"; do + [ "$existing" != "$home" ] || known=1 + done + [ "$known" = 0 ] || continue + visited+=("$home") + states=("$home/state") + registries=("$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" ] || { + 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 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 + } + 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 +} + +# 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 + 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 +# 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 + 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 -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 +# 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_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() { # + 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 + [ -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 + 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 +} + +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. +# 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 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; +# 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 + 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 +1462,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 +1474,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 +1491,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 +1520,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/docs/configuration.md b/docs/configuration.md index e21c13f8799..c5868020d8a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -538,11 +538,12 @@ 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. 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/fixtures.sh b/tests/fixtures.sh index 559cd661eef..240545e3a4c 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 '* ) SHELL=true bash -c "$a" "$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 +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_LEASE_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-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=$? diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 4aef0afc349..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-) @@ -860,12 +869,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" ;; @@ -1246,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 @@ -1254,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" @@ -1290,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 @@ -1343,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. 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=$? diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index d8cc824f0dd..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)^ @@ -656,7 +662,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,16 +695,17 @@ 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() { 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" @@ -706,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-safety.test.sh b/tests/fm-secondmate-safety.test.sh index 7a69e15fe86..ad283165ee6 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -1990,6 +1990,124 @@ 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 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" 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() { local home subhome childproj childwt fakebin log err rc lock home="$TMP_ROOT/force-lock-home" @@ -3011,6 +3129,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-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 2d39f3607ca..9321a7a6b56 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=$? @@ -689,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 @@ -710,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") @@ -726,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") @@ -740,11 +750,100 @@ 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" +} + +# 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" +} + +# 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 + 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_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 test_linked_spawning_home_rejects_primary_before_refresh test_stale_pool_base_refreshes_before_branching test_non_main_default_branch_refreshes_before_branching 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-tangle-guard.test.sh b/tests/fm-tangle-guard.test.sh index 6f80e3078e0..05e9e938344 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\" && exec \"\${SHELL:-/bin/sh}\" ) Enter" "$rec" \ + "the treehouse lease acquisition line sent to the stable window id must enter the leased slot in a child shell" 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 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