diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 477980b8df..9f37e8f807 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 lease-lifecycle floor or a build missing one of the required lifecycle capabilities; treat it as an upgrade request and rely on `bin/fm-bootstrap.sh` for the exact compatibility check. For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 177ab3ba2c..8f3eb05e3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,13 @@ jobs: command -v herdr >/dev/null || { echo "::error::herdr not on PATH after install"; exit 1; } command -v treehouse >/dev/null || { echo "::error::treehouse not on PATH after install"; exit 1; } herdr --version - treehouse --version + [ "$(treehouse --version | tr -d '[:space:]')" = "v2.1.0" ] || { + echo "::error::expected exact Treehouse pin v2.1.0" + exit 1 + } + treehouse get --help | grep -F -- '--lease-holder' + treehouse return --help | grep -F -- '--if-lease-holder' + treehouse status --help | grep -F -- '--json' status=$(herdr status --json) printf '%s\n' "$status" version=$(printf '%s' "$status" | jq -r '.client.version // empty') diff --git a/AGENTS.md b/AGENTS.md index c1e5272828..6734f79009 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ state/ volatile runtime signals; gitignored .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; leased ship/scout worktrees also record treehouse_lease_holder= and treehouse_lease_state=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 12dc7629eb..6c3fdfba75 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -36,11 +36,11 @@ # (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 -# `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 -# spirit (fm_backend_cmux_current_path below). +# subshell - 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. The current leased-worktree path uses a +# top-level `cd`; the zellij-shaped pwd-marker probe remains the +# authoritative entered-path check (fm_backend_cmux_current_path below). # 3. `read-screen --lines N` has NO herdr-style small-N empty-result bug - # verified N=1..10 all return correctly-clamped, non-empty content. The # "fetch generous, trim locally" pattern is still used for consistency @@ -436,15 +436,14 @@ fm_backend_cmux_target_ready() { # [expected-label] # workaround (bin/backends/zellij.sh:306-347) verbatim in spirit. # # Verified pitfall (finding #2 above): cmux's `current_directory` field DOES -# reflect a `cd` run directly in the surface's own top-level shell, but stays -# FROZEN at whatever directory that shell was in when it launched `treehouse -# get` as a foreground command - it never follows that command's own internal -# `cd` into the acquired worktree. cmux's control socket exposes no -# live-process cwd field either (unlike herdr's `foreground_cwd`), so passive -# polling cannot solve this here any more than it could for zellij. Active -# probe instead: print the surface's `$PWD` with a unique marker (atomically -# submitted via send_text_line), briefly settle, then capture and read only -# that marker line. Scoped to fm-spawn.sh's own worktree-discovery poll loop. +# reflect the top-level `cd` that fm-spawn uses to enter a leased worktree, but +# stays FROZEN when that shell launches a nested foreground subshell and never +# follows the child process's internal `cd`. cmux's control socket exposes no +# live-process cwd field either (unlike herdr's `foreground_cwd`), so the active +# probe remains the authoritative entered-path check: print the surface's `$PWD` +# with a unique marker (atomically submitted via send_text_line), briefly settle, +# then capture and read only that marker line. Scoped to fm-spawn.sh's own +# worktree-discovery poll loop. fm_backend_cmux_current_path() { # [expected-label] local target=$1 expected_label=${2:-} out line marker_begin="__FM_CMUX_CWD_BEGIN__" marker_end="__FM_CMUX_CWD_END__" in_block=0 chunk="" last="" fm_backend_cmux_target_ready "$target" "$expected_label" || return 0 @@ -504,8 +503,8 @@ fm_backend_cmux_send_key() { # [expected-label] # fm_backend_cmux_send_text_line: send one line of TEXT then submit. cmux has # no single-call atomic "run and submit" primitive (like herdr's `pane run`), # so this composes send (literal) + send-key enter, exactly like zellij's -# equivalent - used for the fixed spawn-time commands (treehouse get, the -# GOTMPDIR export). +# equivalent - used for the fixed spawn-time commands (cd into the leased +# worktree, the GOTMPDIR export). fm_backend_cmux_send_text_line() { # [expected-label] fm_backend_cmux_send_literal "$1" "$2" "${3:-}" || return 1 fm_backend_cmux_send_key "$1" Enter "${3:-}" diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 77c48a84a4..a94c703e5c 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2275,16 +2275,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 spawn-time cd into the leased 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 (as the spawn-time enter into a leased worktree 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 -# worktree subshell - confirmed live against a real treehouse acquisition. +# process's cwd instead, which is what changes when the pane cds into the +# leased worktree - confirmed live against a real treehouse acquisition. fm_backend_herdr_current_path() { # fm_backend_herdr_target_ready "$1" || return 0 fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane get "$FM_BACKEND_HERDR_PANE" 2>/dev/null \ @@ -2293,7 +2293,7 @@ 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 (cd into the leased worktree, 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 f8da21bf0d..b7da51a44f 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -8,10 +8,10 @@ # 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 -# provider, not the worktree provider, so fm-spawn.sh still drives that part -# inline with these same send/current-path primitives. +# Worktree acquisition (durable treehouse lease under the task id, then cd +# into the leased path and poll the pane cwd) is owned by fm-spawn.sh: P1 +# scopes only the session provider, not the worktree provider, so spawn still +# drives that part inline with these same send/current-path primitives. # # The verified composer/busy-detection and verify-and-retry-submit primitives # already live in bin/fm-tmux-lib.sh, shared with the away-mode daemon @@ -103,8 +103,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 -# inline in fm-spawn.sh. Mirrors `tmux send-keys -t "$T" "" Enter`. +# (cd into the leased worktree, 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 20d53a3c2d..85acaba4c6 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -52,17 +52,15 @@ # stdout, exactly as documented. # 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 -# 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 -# no per-pane pid to read it from `/proc`/`lsof` either). This directly -# contradicts the design report's assumption ("acceptable for tmux and -# zellij") and required a different implementation strategy - see -# fm_backend_zellij_current_path below and docs/zellij-backend.md -# "Worktree-path discovery: pane_cwd does not track a subshell". +# reflect a `cd` performed by a NESTED SUBSHELL the pane's shell launched +# as a foreground command. `pane_cwd` stays frozen at wherever the pane's +# shell was when it invoked that 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 no per-pane pid to +# read it from `/proc`/`lsof` either). The current leased-worktree path is +# entered with a top-level `cd`, while the active marker probe remains the +# authoritative entered-path check; see fm_backend_zellij_current_path +# below and docs/zellij-backend.md. # 5. `new-tab` DOES steal focus from an attached client with NO flag to # suppress it (unlike herdr's --no-focus and tmux's new-window -d). # Mitigated (fm_backend_zellij_create_task): capture the previously @@ -383,15 +381,15 @@ 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 spawn-time cd into a leased 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` -# 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 +# `cd` run directly in the pane's own top-level shell (what fm-spawn uses after +# a durable treehouse lease), but stays FROZEN at whatever directory the pane's +# shell was in when it launched a nested foreground subshell - it never follows +# that command's own internal `cd`, 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 # (unlike herdr's `foreground_cwd`), so passive JSON polling cannot solve # this. Active probe instead: print the pane's `$PWD` with a unique marker @@ -462,8 +460,8 @@ fm_backend_zellij_send_key() { # [expected-label] # fm_backend_zellij_send_text_line: send one line of TEXT then submit, # ATOMICALLY - mirrors tmux's `send-keys -t T text Enter` / herdr's `pane -# run`. Used for the fixed spawn-time commands (treehouse get, the GOTMPDIR -# export). Zellij has no single-call atomic "run and submit" action, so this +# run`. Used for the fixed spawn-time commands (cd into the leased worktree, +# the GOTMPDIR export). Zellij has no single-call atomic "run and submit" action, so this # composes paste (literal) + send-keys Enter, exactly like send_literal + # send_key are composed elsewhere - the two-step form is the ONLY form for # this adapter, unlike tmux/herdr which have a genuinely atomic primitive. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 0ac06b162f..00a1b6f42f 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -45,8 +45,8 @@ # 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 below 2.1.0 or when its installed build +# lacks the get, status, and guarded-return lease lifecycle flags. # no-mistakes is also MISSING when its installed version is older than # 1.31.2. # tasks-axi and quota-axi are required bootstrap tools (same class as @@ -536,9 +536,17 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then fi TOOLS="$BACKEND_TOOLS $COMMON_TOOLS" NO_MISTAKES_MIN=1.31.2 - -treehouse_supports_lease() { - treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' +TREEHOUSE_MIN=2.1.0 + +treehouse_supports_lease_lifecycle() { + local get_help return_help status_help + get_help=$(treehouse get --help 2>&1) || return 1 + return_help=$(treehouse return --help 2>&1) || return 1 + status_help=$(treehouse status --help 2>&1) || return 1 + printf '%s\n' "$get_help" | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' \ + && printf '%s\n' "$get_help" | grep -Eq '(^|[^[:alnum:]_-])--lease-holder([^[:alnum:]_-]|$)' \ + && printf '%s\n' "$return_help" | grep -Eq '(^|[^[:alnum:]_-])--if-lease-holder([^[:alnum:]_-]|$)' \ + && printf '%s\n' "$status_help" | grep -Eq '(^|[^[:alnum:]_-])--json([^[:alnum:]_-]|$)' } # Shared semantic-version floor for the tool gates below. A version string that @@ -865,11 +873,13 @@ done for t in $COMMON_TOOLS; do command -v "$t" >/dev/null || missing_tool_diagnostic "$t" done -# The treehouse lease-support upgrade check is only relevant when the resolved +# The treehouse lease-lifecycle upgrade check is only relevant when the resolved # backend actually requires treehouse (every backend except orca, which owns its # own worktrees); an orca home must not be told to upgrade a provider it never uses. if fm_backend_list_contains "$TOOLS" treehouse \ - && command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then + && command -v treehouse >/dev/null 2>&1 \ + && { ! tool_version_at_least treehouse "$TREEHOUSE_MIN" \ + || ! treehouse_supports_lease_lifecycle; }; then echo "MISSING: treehouse (install: $(install_cmd treehouse))" fi if command -v no-mistakes >/dev/null 2>&1 && ! tool_version_at_least no-mistakes "$NO_MISTAKES_MIN"; then diff --git a/bin/fm-install-treehouse.sh b/bin/fm-install-treehouse.sh index 9d181439c8..c165cb11ff 100755 --- a/bin/fm-install-treehouse.sh +++ b/bin/fm-install-treehouse.sh @@ -9,12 +9,12 @@ # 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 lease-lifecycle floor exercised by real-Herdr CI. 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 +30,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" @@ -68,7 +68,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 @@ -82,7 +82,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 b6ffdfd347..2c5e013169 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -19,10 +19,17 @@ # 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 -# 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. +# terminal, so ship/scout Orca spawns do not use treehouse; cmux is a +# session provider only, exactly like herdr/zellij, so it does. Ship and +# scout treehouse worktrees are acquired with a durable lease held under +# the task id (treehouse get --lease --lease-holder ), then the pane +# cds into that path - never a plain interactive get, which leaves the +# pool slot available for another spawn to steal. Recovery that still has +# a recorded worktree reuses that path only while treehouse still reports +# its task-held lease; marker-free pre-lease worktrees retain their legacy +# reuse behavior. An auto-detected herdr or cmux spawn prints a loud +# stderr notice; auto-detected tmux stays silent; zellij and orca are +# never auto-detected. # codex-app is not a known backend yet; docs/codex-app-backend.md owns that # blocked backend contract. Default tmux spawns do not write backend= to meta; # absent backend= means tmux. cmux does not support --secondmate spawns yet. @@ -163,26 +170,25 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" SUB_HOME_MARKER=".fm-secondmate-home" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn +# a direct report (see bin/fm-gate-refuse-lib.sh). +fm_refuse_if_gate_agent # shellcheck source=bin/fm-ff-lib.sh . "$SCRIPT_DIR/fm-ff-lib.sh" -# shellcheck source=bin/fm-wake-lib.sh +export FM_WAKE_DEFER_STATE_INIT=1 +# shellcheck source=bin/fm-wake-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-wake-lib.sh" +unset FM_WAKE_DEFER_STATE_INIT # shellcheck source=bin/fm-config-inherit-lib.sh . "$SCRIPT_DIR/fm-config-inherit-lib.sh" # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" -# shellcheck source=bin/fm-gate-refuse-lib.sh -. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-busy-lib.sh . "$SCRIPT_DIR/fm-busy-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" -# Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn -# a direct report (see bin/fm-gate-refuse-lib.sh). -fm_refuse_if_gate_agent -# Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is -# set by the batch loop below), so the guard runs once for the batch, not once per pair. -[ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true KIND=ship HARNESS_ARG= MODEL= @@ -233,6 +239,62 @@ case "$EFFORT" in *) echo "error: --effort must be one of low, medium, high, xhigh, max" >&2; exit 1 ;; esac +idpart=${POS[0]:-} +idpart=${idpart%%=*} +if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in */*) false ;; *) true ;; esac; then + if [ "$KIND" != secondmate ] && [ -z "$HARNESS_ARG" ] && [ -f "$CONFIG/crew-dispatch.json" ]; then + echo "error: config/crew-dispatch.json is active - pass an explicit harness resolved from the dispatch rules (the consultation backstop, so the rules are never silently skipped)." >&2 + exit 1 + fi + rc=0 + shared_args=() + [ -z "$HARNESS_ARG" ] || shared_args+=(--harness "$HARNESS_ARG") + [ -z "$MODEL" ] || shared_args+=(--model "$MODEL") + [ -z "$EFFORT" ] || shared_args+=(--effort "$EFFORT") + [ -z "$BACKEND_ARG" ] || shared_args+=(--backend "$BACKEND_ARG") + for pair in "${POS[@]}"; do + case "$pair" in + *=*) : ;; + *) echo "error: batch dispatch expects every argument as id=repo; got '$pair'" >&2; rc=2; continue ;; + esac + if [ "$KIND" = secondmate ]; then + echo "error: batch dispatch does not support --secondmate; spawn each secondmate explicitly" >&2 + rc=2 + continue + elif [ "$KIND" = scout ]; then + if "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" "${shared_args[@]+"${shared_args[@]}"}" --scout; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi + else + if "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" "${shared_args[@]+"${shared_args[@]}"}"; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi + fi + done + exit "$rc" +fi +ID=${POS[0]} +fm_task_id_creation_valid "$ID" || { echo "error: invalid task id" >&2; exit 2; } +SPAWN_HOME_REGISTRATION= +SPAWN_HOME_REGISTRATION_HELD=0 +spawn_home_registration_cleanup() { + local status=$? + if [ "${SPAWN_HOME_REGISTRATION_HELD:-0}" = 1 ]; then + SPAWN_HOME_REGISTRATION_HELD=0 + fm_home_spawn_unregister "$SPAWN_HOME_REGISTRATION" || true + fi + return "$status" +} +trap spawn_home_registration_cleanup EXIT +if ! fm_home_spawn_register "$FM_HOME" "$ID"; then + case "$FM_HOME_LIFECYCLE_ERROR" in + retiring) echo "error: task home retirement is already handling $FM_HOME; refusing spawn $ID" >&2 ;; + active-spawn) echo "error: another spawn is already registering task $ID" >&2 ;; + *) echo "error: could not register spawn $ID under task home $FM_HOME" >&2 ;; + esac + exit 1 +fi +SPAWN_HOME_REGISTRATION=$FM_HOME_SPAWN_REGISTRATION +SPAWN_HOME_REGISTRATION_HELD=1 +mkdir -p "$STATE" +[ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true + # Backend selection (data/fm-backend-design-d7): explicit --backend, else # FM_BACKEND env, else config/backend, else runtime auto-detection, else # default tmux (fm_backend_name). fm_backend_validate_spawn refuses unknown or @@ -261,6 +323,13 @@ fi ORCA_ABORT_CLEANUP=0 ORCA_WORKTREE_ID= ORCA_TERMINAL= +# Set when this spawn freshly leased a treehouse ship/scout worktree; cleared +# after successful meta publication. Abort cleanup returns only a lease this +# spawn acquired, never a recovered/reused worktree whose lease must stay held. +TREEHOUSE_LEASE_ACQUIRED=0 +TREEHOUSE_LEASE_HOLDER= +TREEHOUSE_LEASE_STATE= +META_TMP= HERDR_PROJECTION_ABORT_CLEANUP=0 HERDR_PROJECTION_ABORT_SESSION= HERDR_PROJECTION_ABORT_TASK_PANE= @@ -290,7 +359,7 @@ parse_orca_worktree_result() { } spawn_abort_cleanup() { - local status=$? + local status=$? published_wt published_holder published_state if [ "$HERDR_PROJECTION_ABORT_CLEANUP" = 1 ] \ && [ "$HERDR_PRESENTATION_ORDER_LOCK_HELD" != 1 ]; then if ! spawn_herdr_presentation_order_lock_acquire "${HERDR_PROJECTION_ABORT_SESSION:-}"; then @@ -337,10 +406,36 @@ spawn_abort_cleanup() { fi fi fi + # Release only a treehouse lease this spawn just acquired. A recovery relaunch + # that reuses a recorded worktree never sets TREEHOUSE_LEASE_ACQUIRED, so an + # abort there cannot free a still-owned slot. + if [ "${TREEHOUSE_LEASE_ACQUIRED:-0}" = 1 ] && [ -n "${WT:-}" ]; then + published_wt=$(fm_meta_get "$STATE/$ID.meta" worktree) + published_holder=$(fm_meta_get "$STATE/$ID.meta" treehouse_lease_holder) + published_state=$(fm_meta_get "$STATE/$ID.meta" treehouse_lease_state) + if [ "$published_wt" = "$WT" ] \ + && [ "$published_holder" = "$ID" ] \ + && [ "$published_state" = held ]; then + TREEHOUSE_LEASE_ACQUIRED=0 + else + TREEHOUSE_LEASE_ACQUIRED=0 + if [ -n "${PROJ_ABS:-}" ] && [ -d "$WT" ] && command -v treehouse >/dev/null 2>&1; then + ( cd "$PROJ_ABS" && treehouse return --force --if-lease-holder "$ID" "$WT" ) >/dev/null 2>&1 || true + fi + fi + fi + if [ -n "${META_TMP:-}" ]; then + rm -f "$META_TMP" 2>/dev/null || true + META_TMP= + fi if [ "$SPAWN_TASK_LOCK_HELD" = 1 ]; then SPAWN_TASK_LOCK_HELD=0 fm_lock_release "$SPAWN_TASK_LOCK" || true fi + if [ "$SPAWN_HOME_REGISTRATION_HELD" = 1 ]; then + SPAWN_HOME_REGISTRATION_HELD=0 + fm_home_spawn_unregister "$SPAWN_HOME_REGISTRATION" || true + fi if [ "$CONFIG_INHERIT_LOCK_HELD" = 1 ]; then CONFIG_INHERIT_LOCK_HELD=0 fm_lock_release "$CONFIG_INHERIT_LOCK" || true @@ -375,44 +470,6 @@ spawn_herdr_presentation_order_lock_release() { fm_lock_release "$HERDR_PRESENTATION_ORDER_LOCK" || true } -# Batch dispatch (see header): when the first positional is an `id=repo` pair, treat every -# positional as one and spawn each by re-execing this script in single-task mode. We use -# the FM_ROOT path (not $0) so it works whatever cwd or relative path invoked us, and reuse -# the single path verbatim. A failed pair is reported and skipped; the rest still launch; -# exit is non-zero if any pair failed. Single-task invocations never carry an '=' in arg -# one (task ids are bare slugs), so they fall straight through to the logic below. -idpart=${POS[0]:-} -idpart=${idpart%%=*} -if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in */*) false ;; *) true ;; esac; then - if [ "$KIND" != secondmate ] && [ -z "$HARNESS_ARG" ] && [ -f "$CONFIG/crew-dispatch.json" ]; then - echo "error: config/crew-dispatch.json is active - pass an explicit harness resolved from the dispatch rules (the consultation backstop, so the rules are never silently skipped)." >&2 - exit 1 - fi - rc=0 - shared_args=() - [ -z "$HARNESS_ARG" ] || shared_args+=(--harness "$HARNESS_ARG") - [ -z "$MODEL" ] || shared_args+=(--model "$MODEL") - [ -z "$EFFORT" ] || shared_args+=(--effort "$EFFORT") - [ -z "$BACKEND_ARG" ] || shared_args+=(--backend "$BACKEND_ARG") - for pair in "${POS[@]}"; do - case "$pair" in - *=*) : ;; - *) echo "error: batch dispatch expects every argument as id=repo; got '$pair'" >&2; rc=2; continue ;; - esac - if [ "$KIND" = secondmate ]; then - echo "error: batch dispatch does not support --secondmate; spawn each secondmate explicitly" >&2 - rc=2 - continue - elif [ "$KIND" = scout ]; then - if FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" "${shared_args[@]+"${shared_args[@]}"}" --scout; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi - else - if FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" "${shared_args[@]+"${shared_args[@]}"}"; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi - fi - done - exit "$rc" -fi -ID=${POS[0]} -fm_task_id_creation_valid "$ID" || { echo "error: invalid task id" >&2; exit 2; } SPAWN_TASK_LOCK="$STATE/.spawn-$ID.lock" if ! fm_lock_try_acquire "$SPAWN_TASK_LOCK"; then echo "error: another spawn is already creating task $ID" >&2 @@ -895,6 +952,20 @@ validate_spawn_worktree() { # fi } +recorded_treehouse_lease_is_owned() { # + local worktree=$1 holder=$2 status + # shellcheck disable=SC1003 + case "$worktree" in + *'"'*|*'\'*|*$'\n'*|*'},{'*) return 1 ;; + esac + status=$(CDPATH='' cd -- "$PROJ_ABS" && treehouse status --json) || return 1 + printf '%s\n' "$status" \ + | awk '{ gsub(/\},\{/, "}\n{"); print }' \ + | grep -F "\"path\":\"$worktree\"" \ + | grep -F '"status":"leased"' \ + | grep -F "\"lease_holder\":\"$holder\"" >/dev/null +} + herdr_projection_meta_field_exact() { # local meta=$1 key=$2 count [ -f "$meta" ] && [ ! -L "$meta" ] || return 1 @@ -1293,39 +1364,102 @@ kimi_spawn_fail() { # } if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then - spawn_send_text_line "$WT_TARGET" 'treehouse get' + # Ship/scout worktree acquisition: durable lease under the task id, matching + # secondmate homes (bin/fm-home-seed.sh). A plain interactive `treehouse get` + # leaves the pool slot available, so a later get can hand the same slot to a + # new spawn and hard-reset unlanded work (kunchenguid/firstmate#1441). + # + # Recovery / relaunch: if this task already records a still-present isolated + # worktree, reuse that path only when its recorded task lease is still held, + # or when marker-free metadata identifies a pre-lease worktree. Never call + # get --lease again for the same task identity. + # Pre-existing unleased worktrees from before this change remain usable: spawn + # and teardown do not require a lease marker, and teardown's treehouse return + # still frees the slot when cleanup is allowed. + existing_wt= + existing_lease_holder= + existing_lease_state= + if [ -f "$STATE/$ID.meta" ]; then + existing_wt=$(grep '^worktree=' "$STATE/$ID.meta" 2>/dev/null | cut -d= -f2- || true) + existing_lease_holder=$(fm_meta_get "$STATE/$ID.meta" treehouse_lease_holder) + existing_lease_state=$(fm_meta_get "$STATE/$ID.meta" treehouse_lease_state) + fi + case "$existing_lease_state" in + '') + if [ -n "$existing_lease_holder" ]; then + echo "error: recorded worktree ${existing_wt:-} has a treehouse lease holder without a lease state; refusing recovery" >&2 + exit 1 + fi + ;; + held) + if [ "$existing_lease_holder" != "$ID" ] \ + || [ -z "$existing_wt" ] || [ ! -d "$existing_wt" ] \ + || ! recorded_treehouse_lease_is_owned "$existing_wt" "$ID"; then + echo "error: recorded worktree ${existing_wt:-} is not leased under task $ID; refusing stale recovery" >&2 + exit 1 + fi + TREEHOUSE_LEASE_HOLDER=$ID + TREEHOUSE_LEASE_STATE=held + ;; + returning|returned) + echo "error: recorded worktree ${existing_wt:-} has lease state $existing_lease_state; refusing recovery after return" >&2 + exit 1 + ;; + *) + echo "error: recorded worktree ${existing_wt:-} has unknown lease state $existing_lease_state; refusing recovery" >&2 + exit 1 + ;; + esac + if [ -n "$existing_wt" ] && [ -d "$existing_wt" ]; then + WT=$existing_wt + validate_spawn_worktree "recorded worktree" "$T" + else + WT=$(cd "$PROJ_ABS" && treehouse get --lease --lease-holder "$ID") || { + echo "error: treehouse get --lease failed to lease a worktree for $ID under $PROJ_ABS" >&2 + exit 1 + } + [ -n "$WT" ] || { + echo "error: treehouse get --lease did not report a worktree path for $ID" >&2 + exit 1 + } + TREEHOUSE_LEASE_ACQUIRED=1 + TREEHOUSE_LEASE_HOLDER=$ID + TREEHOUSE_LEASE_STATE=held + # Fail closed on a leased-but-wrong path before the pane is moved into it. + validate_spawn_worktree "treehouse get --lease" "$T" + fi - # 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. - # Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: 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. + # Enter the leased (or recovered) path in the pane. A top-level cd is visible + # to every backend's current-path probe (unlike the old interactive get + # subshell, which some backends cannot observe). + spawn_send_text_line "$WT_TARGET" "cd $(shell_quote "$WT")" + + # Wait until the pane cwd matches the acquired path. 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. + # Compare against the physical form of the leased path: a symlinked project + # prefix would otherwise make the pane's OS-level cwd read differ from the + # logical WT on the very first poll. # - # A single read that already differs from PROJ_ABS_REAL 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 still passes the PROJ_ABS_REAL comparison and validate_spawn_worktree - # below (it resolves to a real, distinct worktree top-level too), so accepting it - # on one read alone silently records the wrong worktree= in state/.meta. Require - # two consecutive reads to agree on the same non-project 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. + # A single matching read is not proof the pane settled: on some tmux/WSL + # setups a brand-new window's pane_current_path transiently reports an + # unrelated path before the shell catches up with cd. Require two consecutive + # reads to agree on the expected leased path before accepting it. + expected_wt_real=$(real_path_or_raw "$WT") candidate="" + settled=0 for _ in $(seq 1 60); do p=$(spawn_current_path "$WT_TARGET" || true) if [ -n "$p" ]; then p_real=$(real_path_or_raw "$p") - if [ "$p_real" != "$PROJ_ABS_REAL" ]; then + if [ "$p_real" = "$expected_wt_real" ]; then if [ -n "$candidate" ] && [ "$p_real" = "$candidate" ]; then - WT="$p" + settled=1 break fi - candidate="$p_real" + candidate=$p_real else candidate="" fi @@ -1334,12 +1468,14 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then fi sleep 1 done - if [ -z "$WT" ]; then - echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 + if [ "$settled" -ne 1 ]; then + echo "error: pane did not enter leased worktree $WT within 60s; inspect window $T" >&2 exit 1 fi - validate_spawn_worktree "treehouse get" "$T" + # Re-check isolation after the pane settled: a leased-but-wrong worktree must + # still fail loudly rather than launch in the primary checkout. + validate_spawn_worktree "treehouse worktree enter" "$T" fi # Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't @@ -1602,6 +1738,10 @@ fi META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W +META_TMP=$(mktemp "$STATE/.${ID}.meta.XXXXXX") || { + echo "error: could not create temporary metadata for task $ID" >&2 + exit 1 +} { echo "window=$META_WINDOW" echo "endpoint_task_id=$ID" @@ -1614,6 +1754,8 @@ META_WINDOW=$T echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" + [ -z "$TREEHOUSE_LEASE_HOLDER" ] || echo "treehouse_lease_holder=$TREEHOUSE_LEASE_HOLDER" + [ -z "$TREEHOUSE_LEASE_STATE" ] || echo "treehouse_lease_state=$TREEHOUSE_LEASE_STATE" [ -z "${BUSY_GEN:-}" ] || echo "busy_gen=$BUSY_GEN" # backend= is written only for a non-default (non-tmux) backend, so the # default path's meta stays byte-identical (absent backend= means tmux; @@ -1642,8 +1784,24 @@ META_WINDOW=$T echo "home=$PROJ_ABS" echo "projects=$SECONDMATE_PROJECTS" fi -} > "$STATE/$ID.meta" +} > "$META_TMP" || { + echo "error: could not write metadata for task $ID" >&2 + exit 1 +} +mv -f "$META_TMP" "$STATE/$ID.meta" || { + echo "error: could not publish metadata for task $ID" >&2 + exit 1 +} +META_TMP= [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 +# Meta now owns the worktree path; abort must not release a successfully +# published ship/scout lease (teardown is the only release path). +TREEHOUSE_LEASE_ACQUIRED=0 +fm_home_spawn_unregister "$SPAWN_HOME_REGISTRATION" || { + echo "error: could not finish spawn registration for $ID under $FM_HOME" >&2 + exit 1 +} +SPAWN_HOME_REGISTRATION_HELD=0 sq_brief=$(shell_quote "$BRIEF") sq_turnend=$(shell_quote "$TURNEND") diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ed1da4fa95..7b578d372d 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -50,8 +50,13 @@ # is the approved discard path that prevalidates child removal targets, discards # child work, kills child runtime endpoints, and removes the retired home. Removing a # leased home releases its durable treehouse lease so the pool slot is freed, -# never left leased forever. If the treehouse return fails, teardown leaves the -# leased home and state in place instead of hiding a still-held lease. +# never left leased forever. Ship and scout treehouse worktrees are leased the +# same way under the task id at spawn (bin/fm-spawn.sh); a successful +# treehouse return here releases that lease so the slot is reusable. A +# refused teardown (dirty or unlanded work, missing scout report, etc.) must +# never reach return, so the lease stays held and the pool cannot hand the +# slot to another spawn. If the treehouse return fails, teardown leaves the +# leased home or worktree and state in place instead of hiding a still-held lease. # Usage: fm-teardown.sh [--force] # --force skips ordinary-task dirty and landed-work checks, skips scout report # checks, and discards secondmate child work for kind=secondmate. Only use it @@ -119,9 +124,46 @@ FORCE=${2:-} # Fail closed before any fleet mutation: a no-mistakes gate agent must never tear # down a worktree (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" FM_LOCK_LOG_PREFIX=teardown META="$STATE/$ID.meta" +TEARDOWN_TASK_LOCK="$STATE/.spawn-$ID.lock" +TEARDOWN_TASK_LOCK_HELD=0 +TEARDOWN_CHILD_TASK_LOCKS= +TEARDOWN_HOME_RETIREMENT_LOCKS= +teardown_release_task_lock() { + local child_lock home_lock + if [ -n "$TEARDOWN_CHILD_TASK_LOCKS" ]; then + while IFS= read -r child_lock; do + [ -n "$child_lock" ] || continue + fm_lock_release "$child_lock" || true + done <&2 + exit 1 +fi +TEARDOWN_TASK_LOCK_HELD=1 +trap teardown_release_task_lock EXIT [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } # This is the first cleanup authorization check. It is metadata-only and must # complete before fm-guard, a backend command, file removal, branch deletion, @@ -145,6 +187,59 @@ if [ -z "$BUSY_GEN" ]; then fi ORCA_WORKTREE_ID=$(fm_meta_get "$META" orca_worktree_id) ORCA_PATH_MATCH_VERIFIED=0 +TREEHOUSE_LEASE_HOLDER=$(fm_meta_get "$META" treehouse_lease_holder) +TREEHOUSE_LEASE_STATE=$(fm_meta_get "$META" treehouse_lease_state) + +case "$TREEHOUSE_LEASE_STATE" in + '') + if [ -n "$TREEHOUSE_LEASE_HOLDER" ]; then + echo "REFUSED: task $ID has a treehouse lease holder without a lease state; preserving metadata." >&2 + exit 1 + fi + ;; + held|returned) + if [ "$TREEHOUSE_LEASE_HOLDER" != "$ID" ]; then + echo "REFUSED: task $ID has mismatched treehouse lease holder ${TREEHOUSE_LEASE_HOLDER:-}; preserving metadata." >&2 + exit 1 + fi + ;; + returning) + echo "REFUSED: task $ID has an indeterminate treehouse return; preserving metadata for manual lease inspection." >&2 + exit 1 + ;; + *) + echo "REFUSED: task $ID has unknown treehouse lease state $TREEHOUSE_LEASE_STATE; preserving metadata." >&2 + exit 1 + ;; +esac +if [ "$TREEHOUSE_LEASE_STATE" = held ] && { [ -z "$WT" ] || [ ! -d "$WT" ]; }; then + echo "REFUSED: task $ID has a held treehouse lease but its worktree is unavailable; preserving metadata." >&2 + exit 1 +fi + +treehouse_lease_state_set_for_meta() { # + local meta=$1 task_id=$2 state=$3 state_dir tmp + state_dir=$(dirname "$meta") + tmp=$(mktemp "$state_dir/.${task_id}.meta.treehouse-lease.XXXXXX") || return 1 + if ! { grep -v '^treehouse_lease_state=' "$meta" || true; } > "$tmp"; then + rm -f "$tmp" + return 1 + fi + printf 'treehouse_lease_state=%s\n' "$state" >> "$tmp" || { + rm -f "$tmp" + return 1 + } + mv -f "$tmp" "$meta" || { + rm -f "$tmp" + return 1 + } +} + +treehouse_lease_state_set() { # + local state=$1 + treehouse_lease_state_set_for_meta "$META" "$ID" "$state" || return 1 + TREEHOUSE_LEASE_STATE=$state +} KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship @@ -668,12 +763,16 @@ 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() { - local dir=$1 cd_dir=$2 label=$3 post_cleanup_check=${4:-} + local dir=$1 cd_dir=$2 label=$3 post_cleanup_check=${4:-} lease_holder=${5:-} local out lock attempt=0 max_retries lock_desc + local -a return_args + return_args=(return --force) + [ -z "$lease_holder" ] || return_args+=(--if-lease-holder "$lease_holder") + return_args+=("$dir") # 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=$( ( cd "$cd_dir" && treehouse "${return_args[@]}" ) 2>&1 ); then [ -n "$out" ] && printf '%s\n' "$out" return 0 fi @@ -698,7 +797,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=$( ( cd "$cd_dir" && treehouse "${return_args[@]}" ) 2>&1 ); then [ -n "$out" ] && printf '%s\n' "$out" echo "teardown: $label return succeeded on retry; lock cleared on its own" >&2 return 0 @@ -725,7 +824,7 @@ teardown_treehouse_return() { return 1 fi fi - if out=$( ( cd "$cd_dir" && treehouse return --force "$dir" ) 2>&1 ); then + if out=$( ( cd "$cd_dir" && treehouse "${return_args[@]}" ) 2>&1 ); then [ -n "$out" ] && printf '%s\n' "$out" echo "teardown: $label return succeeded after stale-lock cleanup" >&2 return 0 @@ -1044,13 +1143,139 @@ remove_firstmate_home() { safe_rm_rf "$abs_home_path" "$label" } +treehouse_lease_metadata_validate() { #