diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 96560e62a18..cf3291ee4da 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 missing any of the pool-claim lease flags (`get --lease`, `get --lease-holder`, `return --if-lease-holder`); 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/backends/cmux.sh b/bin/backends/cmux.sh index 747d3fc2ccd..348186ee709 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -36,7 +36,8 @@ # (herdr-shape): `workspace list`'s `current_directory` field reflects a # `cd` run directly in the surface's own top-level shell, but stays # frozen at wherever that shell was when it launched a foreground -# subshell (exactly what `treehouse get` does) - verified live: a nested +# subshell (exactly what the spawn-time `(cd -- '' && exec $SHELL)` +# does) - verified live: a nested # `bash -c 'cd /Users && exec bash'` left `current_directory` reporting # the PARENT shell's last cwd, never following into the subshell. Fixed # with zellij's own pwd-marker-probe workaround, reused verbatim in diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 88a8cb61492..33b0301f626 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2081,8 +2081,10 @@ fm_backend_herdr_explicit_close_pane_confirmed() { # # shell - every foreground process is a recognized shell AND no # descendant of the pane shell is a verified harness: positive # proof the pane is shell-only. The descendant walk is what makes -# this safe for the crew shape, where a nested `treehouse get` -# shell sits under the pane's top shell. +# this safe for the crew shape, where the nested worktree +# shell fm-spawn.sh starts with +# `(cd -- '' && exec $SHELL)` sits under the pane's top +# shell. # other - the foreground group holds something that is neither: a tool # the agent is running in its own process group, a pager, a # stranger's process. Not a shell-only pane. An idle shell @@ -2933,15 +2935,16 @@ fm_backend_herdr_target_ready() { # # fm_backend_herdr_current_path: the live FOREGROUND process's cwd, or empty on # any error. Mirrors tmux's pane_current_path poll used for worktree-path -# discovery after `treehouse get`. +# discovery after the pane enters its worktree. # # Verified pitfall: `pane get`'s `.result.pane.cwd` is the pane's cwd AT # CREATION TIME - the top-level shell's cwd - and does NOT update when that -# shell `cd`s or enters a subshell (as `treehouse get` does). Reading it here +# shell `cd`s or enters a subshell (as fm-spawn.sh's +# `(cd -- '' && exec $SHELL)` does). Reading it here # would make fm-spawn.sh's worktree-discovery poll never see the pane "leave" # the project directory, since `cwd` stays frozen at the original path forever. # `.result.pane.foreground_cwd` tracks the ACTUALLY RUNNING foreground -# process's cwd instead, which is what changes when `treehouse get` enters its +# process's cwd instead, which is what changes when that command enters its # worktree subshell - confirmed live against a real treehouse acquisition. fm_backend_herdr_current_path() { # fm_backend_herdr_target_ready "$1" || return 0 @@ -2951,7 +2954,8 @@ fm_backend_herdr_current_path() { # # fm_backend_herdr_send_text_line: send one line of TEXT then submit, # ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed -# spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types +# spawn-time commands (the worktree-entry subshell, the GOTMPDIR export). +# `pane run` types # the command and submits it in one call (verified). fm_backend_herdr_send_text_line() { # fm_backend_herdr_target_ready "$1" || return 1 diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 2bc1c8aa0d7..e9c70aeeba8 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -8,8 +8,9 @@ # default (tmux, `backend=` absent) path stays byte-identical. Sourced only # through bin/fm-backend.sh's fm_backend_source, never directly. # -# Worktree acquisition (running `treehouse get` inside the pane, and polling -# its cwd) is unchanged by this extraction: P1 scopes only the session +# Worktree acquisition (fm-spawn.sh takes the pool's lease itself, sends +# `(cd -- '' && exec $SHELL)` into the pane, and polls its cwd) is +# unchanged by this extraction: P1 scopes only the session # provider, not the worktree provider, so fm-spawn.sh still drives that part # inline with these same send/current-path primitives. # @@ -115,7 +116,8 @@ fm_backend_tmux_current_path() { # # fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no # composer verification - used for the fixed spawn-time commands -# (`treehouse get`, the GOTMPDIR export) that already ran this exact sequence +# (the `(cd -- '' && exec $SHELL)` worktree entry, the GOTMPDIR export) +# that already ran this exact sequence # inline in fm-spawn.sh. Mirrors `tmux send-keys -t "$T" "" Enter`. fm_backend_tmux_send_text_line() { # tmux send-keys -t "$1" "$2" Enter diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index a90247899f1..bfac5a2ad83 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -53,8 +53,8 @@ # 4. `list-panes --json`'s `pane_cwd` reflects a `cd` run DIRECTLY in the # pane's own top-level shell within one poll (<0.3s) - but does NOT # reflect a `cd` performed by a NESTED SUBSHELL the pane's shell -# launched as a foreground command (verified: `treehouse get` opens -# exactly such a subshell). `pane_cwd` stays frozen at wherever the +# launched as a foreground command (verified: the spawn-time +# `(cd -- '' && exec $SHELL)` opens exactly such a subshell). `pane_cwd` stays frozen at wherever the # pane's shell was when it invoked that foreground command - worse than # herdr's frozen-cwd trap (herdr at least exposes a `foreground_cwd` # that tracks this; zellij's CLI exposes no live-process cwd field and @@ -388,13 +388,13 @@ fm_backend_zellij_target_ready() { # [expected-label] # fm_backend_zellij_current_path: the live pane's cwd, or empty on any error. # Mirrors tmux's pane_current_path poll used for worktree-path discovery after -# `treehouse get`. +# the pane enters its worktree. # # Verified pitfall (docs/zellij-backend.md "Worktree-path discovery: pane_cwd # does not track a subshell"): `list-panes --json`'s `pane_cwd` DOES reflect a # `cd` run directly in the pane's own top-level shell, but stays FROZEN at -# whatever directory the pane's shell was in when it launched `treehouse get` -# as a foreground command - it never follows that command's own internal `cd` +# whatever directory the pane's shell was in when it launched the +# worktree-entry command as a foreground command - it never follows that command's own internal `cd` # into the acquired worktree, even after the subshell is fully interactive and # a `pwd` typed into it prints the correct live path on screen. Zellij's CLI # exposes no per-pane pid and no live-process cwd field to read instead diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index 1d14f4ef80b..163357d5ceb 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -316,10 +316,12 @@ fm_backlog_transition_applies() { # return 0 } -# Run `tasks-axi` with an optional FM_TASKS_AXI_TIMEOUT bound. A caller that -# holds a lock across the call - the spawn commit and its preservation -# read-back run under the per-task meta lock - sets the bound, so an -# unresponsive tasks-axi cannot hold that lock open indefinitely; a timed-out +# Run a command with an optional timeout bound. Every caller that holds a lock +# across an external call uses this one runner: the spawn commit and its +# preservation read-back under the per-task meta lock (FM_TASKS_AXI_TIMEOUT), +# and the pool allocation under the shared Treehouse project lock +# (FM_TREEHOUSE_GET_TIMEOUT), so an unresponsive tool cannot hold a lock open +# indefinitely; a timed-out # call exits 124, or 137 when the kill-after had to fire (GNU timeout's own # status for a KILL-forced expiry), and the callers treat either as the bound # expiring and report the timeout as the reason through their existing error @@ -328,34 +330,35 @@ fm_backlog_transition_applies() { # # elsewhere (a stock macOS host has perl but no timeout variant; perl is # already a hard dependency of this library's byte validators, so the # fallback adds no new tool). Every bounded path forces termination: a -# tasks-axi that ignores SIGTERM must not outlive the bound, since an +# command that ignores SIGTERM must not outlive the bound, since an # unbounded call under the lock is exactly the hang the bound exists to # prevent - so the GNU variants carry a kill-after of one further bound # (TERM at the bound, KILL after that grace) and the watchdog kills the # same way. When a bound was requested but no bounding mechanism exists at # all, the call fails closed instead of running unbounded. Must be the last -# command of a subshell: the exec keeps the tasks-axi process exactly where +# command of a subshell: the exec keeps the bounded process exactly where # the plain call sat, and the bound kills the child, not the caller. -fm_tasks_axi_timeout_expired() { # +fm_run_bounded_timed_out() { # case $1 in 124 | 137) return 0 ;; esac return 1 } -fm_tasks_axi() { - local bound=${FM_TASKS_AXI_TIMEOUT:-} +fm_run_bounded() { # [arg...] + local bound=$1 + shift if [ -z "$bound" ]; then - exec tasks-axi "$@" + exec "$@" fi if command -v timeout >/dev/null 2>&1; then - exec timeout -k "$bound" "$bound" tasks-axi "$@" + exec timeout -k "$bound" "$bound" "$@" elif command -v gtimeout >/dev/null 2>&1; then - exec gtimeout -k "$bound" "$bound" tasks-axi "$@" + exec gtimeout -k "$bound" "$bound" "$@" elif command -v perl >/dev/null 2>&1; then - # Fork, run tasks-axi in the child, and poll waitpid(WNOHANG) until the + # Fork, run the command in the child, and poll waitpid(WNOHANG) until the # child exits or the bound expires: the same contract as - # `timeout $bound tasks-axi ...`. Expiry kills the child with TERM, waits + # `timeout $bound ...`. Expiry kills the child with TERM, waits # one further bound of grace, then KILL, and exits 124 so the callers' # timeout plumbing reports it. Polling rather than alarm+die keeps the # bound off perl's platform-dependent syscall-restart signal semantics. @@ -387,12 +390,17 @@ fm_tasks_axi() { select undef, undef, undef, $step; $elapsed += $step; } - ' -- "$bound" tasks-axi "$@" + ' -- "$bound" "$@" fi - printf 'fm_tasks_axi: cannot bound tasks-axi within %ss: none of timeout, gtimeout, or perl is available\n' "$bound" >&2 + printf 'fm_run_bounded: cannot bound %s within %ss: none of timeout, gtimeout, or perl is available\n' "$1" "$bound" >&2 exit 127 } +# The tasks-axi-shaped wrapper the backlog paths call. +fm_tasks_axi() { + fm_run_bounded "${FM_TASKS_AXI_TIMEOUT:-}" tasks-axi "$@" +} + # Print one row's `tasks-axi show` output (plus stderr) from the addressing # fm_backlog_tasks_axi_addressing resolved, with `--file` only for the markdown # backend. Addressing or backend-resolution errors return before tasks-axi runs; @@ -496,7 +504,7 @@ fm_backlog_row_probe() { # else FM_BACKLOG_ROW_ERROR=$(printf '%s\n' "$out" | sed -n '1p') if [ -z "$FM_BACKLOG_ROW_ERROR" ]; then - if fm_tasks_axi_timeout_expired "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then + if fm_run_bounded_timed_out "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then FM_BACKLOG_ROW_ERROR="tasks-axi show $id did not finish within ${FM_TASKS_AXI_TIMEOUT}s" else FM_BACKLOG_ROW_ERROR="tasks-axi show $id failed with no output" @@ -549,7 +557,7 @@ fm_backlog_mutate() { # [flag...] [ "$command_status" -ne 0 ] || return 0 FM_BACKLOG_TRANSITION_ERROR=$(printf '%s\n' "$out" | sed -n '1p') if [ -z "$FM_BACKLOG_TRANSITION_ERROR" ]; then - if fm_tasks_axi_timeout_expired "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then + if fm_run_bounded_timed_out "$command_status" && [ -n "${FM_TASKS_AXI_TIMEOUT:-}" ]; then FM_BACKLOG_TRANSITION_ERROR="tasks-axi $verb $id did not finish within ${FM_TASKS_AXI_TIMEOUT}s" else FM_BACKLOG_TRANSITION_ERROR="tasks-axi $verb $id failed with no output" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 11cd240d5a5..c6562404dd2 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -938,8 +938,19 @@ NO_MISTAKES_MIN=1.46.0 GH_AXI_MIN=0.1.29 LAVISH_AXI_MIN=0.1.46 +treehouse_advertises_flag() { # + treehouse "$1" --help 2>&1 | + grep -Eq "(^|[^[:alnum:]_-])$2([^[:alnum:]_-]|$)" +} + +# Both halves of a task's pool claim, not just the half that takes it: the spawn +# leases the slot with `get --lease --lease-holder` and every release path - +# aborted spawn, operator recovery - matches on `return --if-lease-holder`, so a +# build carrying only the first leaves claims nothing can release. treehouse_supports_lease() { - treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' + treehouse_advertises_flag get --lease && + treehouse_advertises_flag get --lease-holder && + treehouse_advertises_flag return --if-lease-holder } # Shared semantic-version floor for the tool gates below. A version string that diff --git a/bin/fm-install-treehouse.sh b/bin/fm-install-treehouse.sh index b0ec7816679..a4b01e2a38a 100755 --- a/bin/fm-install-treehouse.sh +++ b/bin/fm-install-treehouse.sh @@ -9,12 +9,14 @@ # Usage: # fm-install-treehouse.sh # -# Pins Treehouse v2.0.1, the version exercised by the local real-Herdr suite. +# Pins Treehouse v2.1.0, the version exercised by the local real-Herdr suite and +# the floor for the pool claim: `get --lease --lease-holder` and the +# holder-matched `return --if-lease-holder` release bin/fm-bootstrap.sh gates on. set -eu -FM_TREEHOUSE_CI_VERSION=2.0.1 +FM_TREEHOUSE_CI_VERSION=2.1.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.1.0 archives are under 8 MiB. FM_TREEHOUSE_CI_MAX_BYTES=15000000 FM_TREEHOUSE_CI_REPO=kunchenguid/treehouse @@ -30,19 +32,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=ff030255663bb5d384309cdf1b3a0bd62006e0ac978340d292039697cb70c225 ;; Linux-aarch64|Linux-arm64) ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-linux-arm64.tar.gz - SHA256=eaccc9c5b98125df8bd77425598eeecee66cb0371db4eb1cf75f0d813c18fab9 + SHA256=d4e37ad11d50fb6381c060d34e7372e944e01af78b0c6707c6d0ff966c7c2c44 ;; Darwin-arm64) ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-darwin-arm64.tar.gz - SHA256=7ee5078f3d1f33c01196548797fce65408e459d53530b77d4ba56e074fa1c1a2 + SHA256=a2e9bcba88d643a828062f3425dcf601886372fc2aca87b542b36fc8bbf2c992 ;; Darwin-x86_64) ARCHIVE=treehouse-v${FM_TREEHOUSE_CI_VERSION}-darwin-amd64.tar.gz - SHA256=1cf44580a5837f995e1d3bb74f4fbd3112b642acd20406087d9735a8106112fd + SHA256=8eb2eb3b63f409a316bd9c19af5d3035e5b72f6feffc98f0fac658e1ed33a397 ;; *) die "unsupported platform ${os}-${arch}; official Treehouse assets are linux/darwin amd64 and arm64" @@ -69,7 +71,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.1.0). if [ -f "$TMP/treehouse" ]; then BIN="$TMP/treehouse" elif [ -f "$TMP/treehouse-v${FM_TREEHOUSE_CI_VERSION}/treehouse" ]; then @@ -83,7 +85,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.1.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 9c35c7b014b..2140212d537 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -63,7 +63,7 @@ # then tmux. # Spawn-capable backends are the reference tmux adapter and experimental # herdr, zellij, orca, and cmux. Orca owns both the task worktree and -# terminal, so ship/scout Orca spawns do not run treehouse get; cmux is a +# terminal, so ship/scout Orca spawns take no pool worktree; cmux is a # session provider only, exactly like herdr/zellij, so it does. An # auto-detected herdr or cmux spawn prints a loud stderr notice; # auto-detected tmux stays silent; zellij and orca are never auto-detected. @@ -202,13 +202,17 @@ # Ship/scout spawns refuse to launch unless the resolved task path is a real # git worktree root distinct from both the spawning project and its repository's # primary checkout, including when the spawning project is a linked worktree. -# On the backends that discover that path by reading the task pane's own cwd, -# the same isolation test screens every read: a pane still showing the project -# or the repository primary while `treehouse get` prepares the slot is waited -# out as a transient rather than adopted and then refused, so a home that is -# itself a linked worktree of the project repository still launches. A pane -# that never reaches an isolated worktree refuses at the end of that wait, -# naming the last path seen and why it was rejected. +# The task worktree is a durable treehouse LEASE fm-spawn takes itself, held +# under a label naming the task record that owns it, so a copy whose agent is +# not currently running is never handed to another spawn and reset under it. +# The isolation test screens the leased path before the pane is touched at all. +# The pane is then moved into that copy and must be observed there: a pane +# still showing the project or the repository primary in its first reads is +# waited out as a transient, so a home that is itself a linked worktree of the +# project repository still launches, and a pane that never arrives refuses at +# the end of that wait, naming the leased copy and the last path seen. +# An aborted spawn returns the lease before any record can name it; otherwise +# the published record owns it and bin/fm-teardown.sh's return releases it. # That placement is proven only at launch. Every ship or scout pane therefore # also receives `export FM_TASK_ID=` before the launch command, on # the same channel as GOTMPDIR, and bin/fm-test-run.sh refuses to execute the @@ -1094,6 +1098,9 @@ SPAWN_TASK_SET_LOCK_HELD=0 SPAWN_TREEHOUSE_PROJECT_LOCK= SPAWN_TREEHOUSE_PROJECT_LOCK_HELD=0 SPAWN_SLOT_CLAIMED=0 +SPAWN_TREEHOUSE_LEASE_HOLDER= +SPAWN_TREEHOUSE_LEASE_PATH= +SPAWN_TREEHOUSE_LEASE_PENDING=0 RELAUNCH_REPLACEMENT_PENDING=0 RELAUNCH_REPLACEMENT_BUSY_GEN= RELAUNCH_REPLACEMENT_HARNESS= @@ -1217,7 +1224,11 @@ spawn_abort_cleanup() { fm_lock_release "$SPAWN_TASK_LOCK" || true fi if [ "$SPAWN_FRESH_COMMIT_PENDING" = 1 ]; then - if ! spawn_fresh_commit_rollback; then + if spawn_fresh_commit_rollback; then + if [ "$SPAWN_TREEHOUSE_LEASE_PENDING" != 1 ] && [ -n "$SPAWN_TREEHOUSE_LEASE_HOLDER" ]; then + echo "warning: the record removed by failed-dispatch cleanup was what named task $ID's pool claim on $SPAWN_TREEHOUSE_LEASE_PATH; a worker may already be live in that copy, so inspect it first, then release the claim with: (cd '$PROJ_ABS' && treehouse return --force --if-lease-holder '$SPAWN_TREEHOUSE_LEASE_HOLDER' '$SPAWN_TREEHOUSE_LEASE_PATH')" >&2 + fi + else status=1 fi fi @@ -1242,6 +1253,21 @@ spawn_abort_cleanup() { echo "warning: leaving task $ID's slot claim on $WT in place; the Treehouse project lock is no longer held, so the next spawn's claim replaces it" >&2 fi fi + if [ "$SPAWN_TREEHOUSE_LEASE_PENDING" = 1 ] && + [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ]; then + # The spawn is aborting before any task record can name this lease, so the + # slot would otherwise stay claimed by a task that never existed. Return it + # while the project lock is still held, and match on the holder label so a + # slot some later allocation has already taken over is never returned here. + # A record that survived the abort (its rollback failed) names this lease + # itself, so the release is skipped: handing that copy back to the pool is + # the reassignment this claim exists to prevent. + SPAWN_TREEHOUSE_LEASE_PENDING=0 + SPAWN_LEASE_RELEASE_OUT=$( cd "$PROJ_ABS" && treehouse return --force \ + --if-lease-holder "$SPAWN_TREEHOUSE_LEASE_HOLDER" \ + "$SPAWN_TREEHOUSE_LEASE_PATH" 2>&1 ) \ + || echo "warning: could not release the treehouse lease on $SPAWN_TREEHOUSE_LEASE_PATH held as $SPAWN_TREEHOUSE_LEASE_HOLDER (treehouse said: ${SPAWN_LEASE_RELEASE_OUT:-nothing}); release it with: (cd '$PROJ_ABS' && treehouse return --force --if-lease-holder '$SPAWN_TREEHOUSE_LEASE_HOLDER' '$SPAWN_TREEHOUSE_LEASE_PATH')" >&2 + fi if [ "$SPAWN_TREEHOUSE_PROJECT_LOCK_HELD" = 1 ]; then SPAWN_TREEHOUSE_PROJECT_LOCK_HELD=0 fm_lock_release "$SPAWN_TREEHOUSE_PROJECT_LOCK" || true @@ -2720,14 +2746,13 @@ real_path_or_raw() { # # left holding the worktree root the check read, and SPAWN_WT_REASON a short # phrase naming why a rejected path failed, both for the refusal messages. # -# The worktree-discovery poll below reads this same predicate, so it can never -# adopt a path the guard would then refuse. That matters because a pane's cwd -# read is a snapshot of whatever process is in the foreground: while `treehouse -# get` is still fetching and checking a slot out, it reports the REPOSITORY's -# primary checkout as its own cwd. That path differs from a linked spawning -# project, so a poll comparing only against the project accepted it, and the -# guard then refused a launch whose slot treehouse went on to create normally. -# A read like that is a transient, not a destination: the poll keeps waiting. +# Its callers are the two validate_spawn_worktree sites: the fresh path screens +# the copy the pool leased BEFORE the pane is told to enter it, so a path that +# is not this project's own isolated worktree is refused while nothing but the +# lease has happened yet, and a relaunch screens the copy its record already +# names. Nothing reads a pane's cwd to decide which +# copy a task owns any more - the lease does that - so this predicate no longer +# has to tell a worktree apart from a transient foreground cwd. SPAWN_WT_TOP= SPAWN_WT_REASON= spawn_worktree_isolated() { # @@ -2784,7 +2809,7 @@ spawn_worktree_isolated() { # validate_spawn_worktree() { # local source=$1 inspect_target=$2 if ! spawn_worktree_isolated "$WT"; then - echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${SPAWN_WT_TOP:-none}'; spawning project '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2 + echo "error: $source did not yield an isolated worktree (resolved '$WT': ${SPAWN_WT_REASON:-no reason recorded}; worktree root '${SPAWN_WT_TOP:-none}'; spawning project '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2 exit 1 fi } @@ -3687,77 +3712,124 @@ 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' - - # 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 - # automatic-rename slips through), display-message -t falls back to the - # active client's window, which would misread firstmate's OWN pane path as the - # worktree and tangle a hook into the primary checkout. The window id never lies. - # The project comparison is physical: spawn_worktree_isolated screens each - # read against PROJ_ABS_REAL, not PROJ_ABS, because a symlinked project prefix - # would otherwise make the pane's OS-level cwd read differ from PROJ_ABS on - # the very first poll, before the pane has actually moved. - # - # A single read that already looks isolated is not proof the pane settled - # there: on some tmux/WSL setups a brand-new window's pane_current_path - # transiently reports an unrelated stale path (seen live as another real git - # checkout entirely) before the shell catches up with treehouse get's cd. That - # stale path passes spawn_worktree_isolated too (it resolves to a real, - # distinct worktree top-level), so accepting it on one read alone silently - # records the wrong worktree= in state/.meta. Require two consecutive - # reads to agree on the same isolated path before accepting it; a mismatch - # just becomes the new candidate rather than resetting the wait, so a pane - # that is already settled by the first real read only costs the one existing - # inter-poll sleep as confirmation, not a whole extra cycle on top. + # Claim the pool slot from HERE, durably, instead of letting the pane's + # `treehouse get` subshell hold it. Treehouse counts a slot as in use only + # while a process is running inside it, so a task whose agent has exited - + # paused, crashed, or between turns - leaves its own working copy looking + # free. The next `treehouse get` then hands that copy to a new spawn and + # resets it (observed 2026-08-19: a live task's copy was reassigned and reset + # to a detached HEAD, silently, with no refusal). A lease is the pool's own + # durable claim: treehouse never hands a leased slot to a later get and never + # prunes it, with or without a process inside, so the claim is enforced by + # the layer that does the allocating rather than re-derived from task records + # after the copy has already been reset. # - # Every candidate is screened with the isolation guard's own predicate, so a - # read of the project itself or of the repository primary checkout is treated - # as the transient it is and the wait continues, instead of being adopted and - # then refused by the guard. - # A candidate the screen rejects is never adopted, so a host where the pane - # never reaches an isolated worktree spends the whole window before refusing. - # That wait is deliberate - telling a transient apart from a terminal - # misconfiguration would need machinery this path does not want - so the - # refusal has to be self-explaining instead: carry the last path seen and the - # reason it was rejected, and report both at the deadline. - candidate="" - last_seen="" - last_reason="the pane reported no path" - for _ in $(seq 1 60); do - p=$(spawn_current_path "$WT_TARGET" || true) - [ -z "$p" ] || last_seen="$p" - if [ -n "$p" ] && spawn_worktree_isolated "$p"; then - p_real=$(real_path_or_raw "$p") - last_reason="it is an isolated worktree, but no second read agreed with it" - if [ -n "$candidate" ] && [ "$p_real" = "$candidate" ]; then - WT="$p" - break - fi - candidate="$p_real" + # How the claim is released, since a claim nothing can release is the trap + # this fix could otherwise create: + # - Normally bin/fm-teardown.sh returns the worktree, and `treehouse return` + # releases the lease with it. That is the whole ordinary lifecycle. + # - An aborted spawn releases it below in spawn_abort_cleanup, before any + # task record exists to point at it. + # - Otherwise the holder label names the exact record that owns the lease, + # so a lease outliving its task is self-identifying and releasable with + # `treehouse return --force --if-lease-holder