From 8027ec804a6a2367a6bc4202eadea5829a90efc3 Mon Sep 17 00:00:00 2001 From: Parsunic <62620332+Parsunic@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:39:41 -0400 Subject: [PATCH 1/7] feat: support project worktree acquisition commands --- .agents/skills/harness-adapters/SKILL.md | 6 +- .../skills/stuck-crewmate-recovery/SKILL.md | 2 +- README.md | 4 +- bin/backends/herdr.sh | 8 +- bin/backends/tmux.sh | 12 +- bin/backends/zellij.sh | 3 +- bin/fm-spawn.sh | 167 ++++++++++++-- bin/fm-teardown.sh | 63 ++++- docs/architecture.md | 14 +- docs/cmux-backend.md | 7 +- docs/configuration.md | 27 ++- docs/herdr-backend.md | 3 +- docs/zellij-backend.md | 7 +- tests/fm-control-relaunch.test.sh | 11 +- tests/fm-spawn-pool-base-freshen.test.sh | 23 ++ tests/fm-spawn-worktree-settle.test.sh | 217 ++++++++++++++++++ tests/fm-teardown.test.sh | 33 +++ 17 files changed, 551 insertions(+), 56 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 03a9b2893e..11e8b4f231 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -335,7 +335,7 @@ The current tmux and Herdr adapters pass their captures and capability descripto See `docs/herdr-backend.md` "Composer and injection safety" for Herdr's current boundary and `tests/fm-backend-herdr.test.sh` for regression coverage. Startup dialog: the "Run Grok Build in a project directory?" project picker appears ONLY when grok is launched from a non-project directory (home, Desktop, Downloads, `/tmp`). -`fm-spawn` launches inside the treehouse worktree (a git repo root), so the picker never appears and grok treats the worktree as a trusted project automatically - no post-launch keystroke is needed. +`fm-spawn` launches inside the isolated task worktree (a Git repo root), so the picker never appears and grok treats the worktree as a trusted project automatically - no post-launch keystroke is needed. Pin `[hints] project_picker_disabled = true` in `~/.grok/config.toml` if a non-project launch ever needs to skip it. **TRUECOLOR placeholder styling: covered (task afk-herdr-false-pending, 2026-07-10).** @@ -358,7 +358,7 @@ Its `Stop` command fires only when the current workspace holds a `.fm-grok-turne `fm-spawn` writes that per-task pointer (`/.fm-grok-turnend`, gitignored via git info/exclude like the other harnesses' worktree hook files) and a matching registry entry naming this task's `state/.turn-ended`. The hook reads `$GROK_WORKSPACE_ROOT`, which is always set for hooks and equals the worktree. This keeps the hook outside the worktree, needs no trust grant, and writes only firstmate-owned files. -`fm-teardown` removes the worktree pointer before returning a pooled worktree. +`fm-teardown` removes the worktree pointer before cleaning up the task worktree. Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detection for them). **Primary-session guard fact (verified 2026-07-28, Grok 0.2.112 and 0.2.73).** @@ -427,7 +427,7 @@ Herdr additionally draws the composer's rules with half-block glyphs, which the FM_HARNESS_LIVENESS_DRIFT=1 bin/fm-test-run.sh tests/fm-harness-liveness-drift-live-e2e.test.sh ``` -Firstmate acquires and enters the treehouse worktree before launching Cursor, then passes that same absolute path through `--workspace`. +Firstmate acquires and enters the isolated task worktree before launching Cursor, then passes that same absolute path through `--workspace`. NEVER pass Cursor's own `-w/--worktree`: it allocates a SECOND worktree under `~/.cursor/worktrees` and would break firstmate's worktree-isolation contract. The raw CLI accepts repeatable `--add-dir ` for deliberate multi-root workspaces; the adapter adds none, and the brief rides inline as the positional prompt, so the private brief directory needs no grant. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index db8b6a08d4..f954666aa1 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -28,7 +28,7 @@ Read the targeted current state with `bin/fm-crew-state.sh ` before deciding A no-mistakes run matched to the crew's branch and current code remains authoritative when the endpoint is dead: handle a terminal or parked run through the normal lifecycle, and keep supervising an active run instead of creating a duplicate worker. When no authoritative run accounts for the task, inspect only its recorded backend and worktree inventory. -Use `treehouse status` for treehouse-backed tmux, herdr, zellij, or cmux tasks, and use the recorded `orca_worktree_id=` and `terminal=` for Orca tasks. +Use `treehouse status` for the default Treehouse-backed tmux, Herdr, Zellij, or cmux tasks, inspect the exact recorded Git worktree directly when metadata says `worktree_provider=project-command`, and use the recorded `orca_worktree_id=` and `terminal=` for Orca tasks. Do not sweep another home's endpoints or infer ownership from a matching window label. Before relaunch, prove that no live agent still owns the recorded task and that the existing worktree remains available. diff --git a/README.md b/README.md index 92fab18637..d047d6b4ff 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. -- **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. +- **Disposable worktrees** - each task runs in a clean [Treehouse](https://github.com/kunchenguid/treehouse), [project-prepared](docs/configuration.md#project-worktree-acquisition), or Orca-managed Git worktree, so parallel work on one repo never collides. - **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, either locally or as a whole home on an SSH-reachable host, with guarded updates and recovery that never turns an unavailable remote route into a local replacement. @@ -150,7 +150,7 @@ Setup guides for tmux (the default) and every other supported backend (herdr, ze │crewmate│ │crewmate│ │crewmate│ one autonomous agent each └───┬────┘ └───┬────┘ └───┬────┘ ▼ ▼ ▼ - treehouse worktree, Orca worktree, or isolated secondmate home + Treehouse/project-prepared worktree, Orca worktree, or isolated secondmate home │ ├─ ship: project mode ► PR/local merge ► teardown │ diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 7367a8db5c..0a53fc7a77 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2522,8 +2522,8 @@ fm_backend_herdr_target_ready() { # # 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 an acquisition command enters +# its 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 \ @@ -2531,8 +2531,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 +# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for spawn-time +# acquisition and environment commands. `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 9eed5f3ec3..21e9172e45 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 (running Treehouse or the trusted project-local command +# inside the pane, and polling its cwd) remains outside this adapter: P1 scopes +# only the session provider, so fm-spawn.sh drives that shared path 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 @@ -106,8 +106,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 +# composer verification - used for spawn-time acquisition and environment +# commands 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 56478f7db3..bf27fd3dad 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -387,8 +387,7 @@ 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`. +# Mirrors tmux's pane_current_path poll used after worktree acquisition. # # 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 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cfb25f0058..db12b7ff1a 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a -# secondmate in its isolated firstmate home. +# Spawn a direct report: a crewmate in a Treehouse, project-prepared, or Orca +# worktree, or a secondmate in its isolated firstmate home. # Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] # fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --secondmate @@ -48,8 +48,9 @@ # 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 +# terminal, so ship/scout Orca spawns do not run the acquisition path; +# tmux, herdr, zellij, and cmux use treehouse get unless the project has a +# local config/worktree-acquire/ command (details below). 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 @@ -89,6 +90,7 @@ # immediately after its owning parent (firstmate or 2ndmate-) contiguous # child block. Ordering never authorizes lifecycle cleanup, and any # unavailable, ambiguous, or failed move warns while the spawn continues. +# Worktree acquisition and task metadata otherwise follow the shared path. # Every projected create, prune, and move captures and verifies the named # session's exact active workspace and tab. A detected focus change restores # only that exact tab id; an ambiguous pre-operation snapshot refuses the @@ -134,8 +136,32 @@ # default-branch commit when safe; skipped syncs warn and launch unchanged. # Ship/scout spawns refuse to launch unless the resolved task path is a real # git worktree root distinct from the primary project checkout. +# A local config/worktree-acquire/ file may replace treehouse get +# for fresh ship/scout acquisition on tmux, herdr, zellij, and cmux. +# is the primary project directory's basename. +# The file contains exactly one non-empty shell command line and must contain +# the literal placeholder at least once. +# The command runs in the project-root shell as trusted local configuration; +# every is replaced with a single-quoted form of the already-validated +# task id, so task input is never interpolated as shell syntax. +# A successful command must leave that shell in the prepared worktree; for a +# creator that does not enter it, use a command shaped like +# `path/to/create && cd path/to/worktrees/`. +# The command's completion status is recorded privately so an immediate +# refusal (including an already-existing target) is reported without waiting +# for the cwd timeout; any existing or partly-created worktree is preserved. +# The usual two-identical-cwd-read isolation proof, 60 one-second polls, base +# refresh, and failure preservation remain unchanged. +# The setting is deliberately ignored by --relaunch, --secondmate, and Orca: +# relaunch adopts the recorded worktree, a secondmate launch adopts its seeded +# home, and Orca owns acquisition itself. +# Successful custom acquisition records worktree_provider=project-command so +# fm-teardown can apply the same landed/dirty checks before guarded native Git +# worktree removal; absent means the existing Treehouse cleanup path. # Before a fresh ship or scout worker starts, its clean task worktree fetches -# origin, resolves the current remote default branch, and resets to its tip. +# origin, resolves the current remote default branch, and resets its current +# attached branch (or detached HEAD) to that tip without checking out another +# branch or detaching it. # An unreachable origin, unresolved default branch, or non-clean worktree # refuses the spawn rather than risking a PR based on stale history. # Batch dispatch: pass one or more `id=repo` pairs instead of a single , e.g. @@ -665,6 +691,10 @@ SPAWN_META_LOCK_HELD=0 SPAWN_META_PUBLISH_STARTED=0 SPAWN_TASK_SET_LOCK= SPAWN_TASK_SET_LOCK_HELD=0 +SPAWN_WORKTREE_ACQUIRE_STATUS= +WORKTREE_PROVIDER= +WORKTREE_ACQUIRE_COMMAND= +WORKTREE_ACQUIRE_CONFIG= RELAUNCH_REPLACEMENT_PENDING=0 RELAUNCH_REPLACEMENT_BUSY_GEN= RELAUNCH_REPLACEMENT_HARNESS= @@ -779,6 +809,8 @@ spawn_abort_cleanup() { fm_lock_release "$SPAWN_CONTROL_LOCK" || true fi [ -z "$SPAWN_META_TMP" ] || rm -f "$SPAWN_META_TMP" 2>/dev/null || true + [ -z "$SPAWN_WORKTREE_ACQUIRE_STATUS" ] \ + || rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" 2>/dev/null || true if [ "$CONFIG_INHERIT_LOCK_HELD" = 1 ]; then CONFIG_INHERIT_LOCK_HELD=0 fm_lock_release "$CONFIG_INHERIT_LOCK" || true @@ -1013,6 +1045,14 @@ if [ "$RELAUNCH" -eq 1 ]; then MODE=$(fm_meta_get "$RELAUNCH_META" mode) YOLO=$(fm_meta_get "$RELAUNCH_META" yolo) RELAUNCH_WT=$(fm_meta_get "$RELAUNCH_META" worktree) + WORKTREE_PROVIDER=$(fm_meta_get "$RELAUNCH_META" worktree_provider) + case "$WORKTREE_PROVIDER" in + ''|project-command) ;; + *) + echo "error: task $ID records unsupported worktree provider '$WORKTREE_PROVIDER'; refusing to relaunch without a known cleanup contract" >&2 + exit 1 + ;; + esac [ -n "$RELAUNCH_WT" ] && [ -d "$RELAUNCH_WT" ] || { echo "error: task $ID's recorded worktree '${RELAUNCH_WT:-none}' is missing; refusing to relaunch without the local copy its work lives in" >&2 exit 1 @@ -1701,6 +1741,53 @@ real_path_or_raw() { # fi } +resolve_worktree_acquire_command() { + local project_name line extra quoted_slug + project_name=$(basename "$PROJ_ABS") + WORKTREE_ACQUIRE_CONFIG="$CONFIG/worktree-acquire/$project_name" + if [ ! -e "$WORKTREE_ACQUIRE_CONFIG" ] && [ ! -L "$WORKTREE_ACQUIRE_CONFIG" ]; then + WORKTREE_ACQUIRE_CONFIG= + WORKTREE_ACQUIRE_COMMAND='treehouse get' + WORKTREE_PROVIDER= + return 0 + fi + if [ ! -f "$WORKTREE_ACQUIRE_CONFIG" ]; then + echo "error: project worktree acquisition config is not a regular file: $WORKTREE_ACQUIRE_CONFIG" >&2 + return 1 + fi + line= + extra= + exec 7< "$WORKTREE_ACQUIRE_CONFIG" || { + echo "error: project worktree acquisition config cannot be read: $WORKTREE_ACQUIRE_CONFIG" >&2 + return 1 + } + if ! IFS= read -r line <&7 && [ -z "$line" ]; then + exec 7<&- + echo "error: project worktree acquisition config must contain exactly one non-empty command line: $WORKTREE_ACQUIRE_CONFIG" >&2 + return 1 + fi + if IFS= read -r extra <&7 || [ -n "$extra" ]; then + exec 7<&- + echo "error: project worktree acquisition config must contain exactly one non-empty command line: $WORKTREE_ACQUIRE_CONFIG" >&2 + return 1 + fi + exec 7<&- + [ -n "$line" ] || { + echo "error: project worktree acquisition config must contain exactly one non-empty command line: $WORKTREE_ACQUIRE_CONFIG" >&2 + return 1 + } + case "$line" in + *''*) ;; + *) + echo "error: project worktree acquisition config must contain the literal placeholder: $WORKTREE_ACQUIRE_CONFIG" >&2 + return 1 + ;; + esac + quoted_slug=$(shell_quote "$ID") + WORKTREE_ACQUIRE_COMMAND=${line//""/$quoted_slug} + WORKTREE_PROVIDER='project-command' +} + # Session-provider container-ensure + task creation. tmux stays exactly as P1 # left it (same session-name / new-window sequence, see bin/backends/tmux.sh); # a herdr spawn goes through the version-gated, workspace-per-HOME, @@ -1727,8 +1814,12 @@ validate_spawn_worktree() { # fi } +# reset --hard moves the current branch ref when HEAD is attached and moves only +# detached HEAD otherwise; it never checks out the default branch or changes the +# attached-vs-detached identity acquired for this task. freshen_spawn_worktree_base() { # - local worktree=$1 default target expected actual status + local worktree=$1 default target expected actual status branch_before branch_after + branch_before=$(git -C "$worktree" symbolic-ref --quiet HEAD 2>/dev/null || true) if ! git -C "$worktree" fetch --quiet origin; then echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 @@ -1762,6 +1853,11 @@ freshen_spawn_worktree_base() { # echo "error: could not reset pooled worktree '$worktree' to '$target'; refusing to launch from a potentially stale base" >&2 return 1 fi + branch_after=$(git -C "$worktree" symbolic-ref --quiet HEAD 2>/dev/null || true) + if [ "$branch_after" != "$branch_before" ]; then + echo "error: refreshing pooled worktree '$worktree' changed its branch identity from '${branch_before:-detached HEAD}' to '${branch_after:-detached HEAD}'; refusing to launch" >&2 + return 1 + fi actual=$(git -C "$worktree" rev-parse --verify --quiet HEAD 2>/dev/null || true) if [ "$actual" != "$expected" ]; then echo "error: pooled worktree '$worktree' is at '${actual:-unknown}', not current '$target' ('$expected'); refusing to launch" >&2 @@ -1769,6 +1865,10 @@ freshen_spawn_worktree_base() { # fi } +if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then + resolve_worktree_acquire_command || exit 1 +fi + herdr_projection_meta_field_exact() { # local meta=$1 key=$2 count [ -f "$meta" ] && [ ! -L "$meta" ] || return 1 @@ -2212,9 +2312,17 @@ 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' + if [ "$WORKTREE_PROVIDER" = project-command ]; then + SPAWN_WORKTREE_ACQUIRE_STATUS="$STATE/.worktree-acquire-$ID.${BASHPID:-$$}.status" + rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" + acquire_status_quoted=$(shell_quote "$SPAWN_WORKTREE_ACQUIRE_STATUS") + acquire_line="__fm_worktree_acquire() { $WORKTREE_ACQUIRE_COMMAND; }; if __fm_worktree_acquire; then __fm_worktree_acquire_rc=0; else __fm_worktree_acquire_rc=\$?; fi; unset -f __fm_worktree_acquire; printf '%s\\n' \"\$__fm_worktree_acquire_rc\" > $acquire_status_quoted; unset __fm_worktree_acquire_rc" + spawn_send_text_line "$WT_TARGET" "$acquire_line" + else + spawn_send_text_line "$WT_TARGET" 'treehouse get' + fi - # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. + # Wait for the acquisition command: 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 @@ -2226,7 +2334,7 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then # 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 + # checkout entirely) before the shell catches up with the acquisition command. 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 @@ -2235,9 +2343,31 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then # 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. candidate="" + acquire_complete=0 for _ in $(seq 1 60); do + if [ "$WORKTREE_PROVIDER" = project-command ] \ + && [ -f "$SPAWN_WORKTREE_ACQUIRE_STATUS" ]; then + acquire_rc= + IFS= read -r acquire_rc < "$SPAWN_WORKTREE_ACQUIRE_STATUS" || { + echo "error: could not read completion status for project worktree acquisition; existing work is preserved. Inspect window $T" >&2 + exit 1 + } + case "$acquire_rc" in + 0) acquire_complete=1 ;; + ''|*[!0-9]*) + echo "error: project worktree acquisition returned an invalid completion status; existing work is preserved. Inspect window $T" >&2 + exit 1 + ;; + *) + echo "error: project worktree acquisition for '$PROJ_ABS' exited with status $acquire_rc before entering an isolated worktree; any existing or partly-created target is preserved. Inspect window $T, land or deliberately remove that target, then retry" >&2 + exit 1 + ;; + esac + fi p=$(spawn_current_path "$WT_TARGET" || true) - if [ -n "$p" ]; then + if [ "$WORKTREE_PROVIDER" = project-command ] && [ "$acquire_complete" -ne 1 ]; then + candidate="" + elif [ -n "$p" ]; then p_real=$(real_path_or_raw "$p") if [ "$p_real" != "$PROJ_ABS_REAL" ]; then if [ -n "$candidate" ] && [ "$p_real" = "$candidate" ]; then @@ -2254,11 +2384,21 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then sleep 1 done if [ -z "$WT" ]; then - echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 + if [ "$WORKTREE_PROVIDER" = project-command ]; then + echo "error: project worktree acquisition did not finish and enter a worktree within 60s; any existing or partly-created target is preserved. Inspect window $T" >&2 + else + echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2 + fi exit 1 fi - validate_spawn_worktree "treehouse get" "$T" + if [ "$WORKTREE_PROVIDER" = project-command ]; then + validate_spawn_worktree "project worktree acquisition" "$T" + rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" + SPAWN_WORKTREE_ACQUIRE_STATUS= + else + validate_spawn_worktree "treehouse get" "$T" + fi fi if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then freshen_spawn_worktree_base "$WT" || exit 1 @@ -2632,7 +2772,7 @@ fi preserve_relaunch_meta() { awk -F= ' BEGIN { - split("window endpoint_task_id worktree project harness kind mode yolo tasktmp model effort busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") + split("window endpoint_task_id worktree project worktree_provider harness kind mode yolo tasktmp model effort busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") for (i in keys) owned[keys[i]] = 1 } !($1 in owned) @@ -2643,6 +2783,7 @@ preserve_relaunch_meta() { echo "endpoint_task_id=$ID" echo "worktree=$WT" echo "project=$PROJ_ABS" + [ -z "$WORKTREE_PROVIDER" ] || echo "worktree_provider=$WORKTREE_PROVIDER" echo "harness=$HARNESS" echo "kind=$KIND" [ -z "$MODE" ] || echo "mode=$MODE" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 4178217c91..9a52b7ceed 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Tear down a finished task: return the treehouse worktree, release the Orca -# worktree, or retire a secondmate home; kill the recorded runtime endpoint, +# Tear down a finished task: return the Treehouse worktree, remove a +# project-command worktree through native Git, release the Orca worktree, or +# retire a secondmate home; kill the recorded runtime endpoint, # clear volatile state, refresh/prune the project's clone for PR-based ship # tasks, then print a backlog-refresh reminder for ship and scout teardowns # (a secondmate teardown prints none, since secondmates are not backlog items). @@ -37,6 +38,13 @@ # Orca tasks use the same safety checks, then close the recorded terminal and # remove the recorded worktree through `orca worktree rm`; teardown never guesses # an Orca target from ambient CLI state. +# A successful custom acquisition records worktree_provider=project-command. +# After the same dirty, landed-work, lock, process-reap, branch-retirement, and +# endpoint-identity checks as a Treehouse task, teardown verifies the recorded +# path is still registered to the recorded primary project and removes it only +# through `git -C worktree remove --force `. +# An absent field keeps the existing Treehouse return path; an unknown value or +# a project-command marker on Orca/secondmate metadata is refused before cleanup. # A Herdr presentation journal never authorizes cleanup. Teardown still closes # only the exact task pane from ordinary endpoint metadata and never calls # `workspace close`. It retires the non-authoritative journal only when a @@ -435,6 +443,22 @@ BACKEND=$FM_BACKEND_VALIDATED_BACKEND T=$FM_BACKEND_VALIDATED_TARGET WT=$(fm_meta_get "$META" worktree) PROJ=$(fm_meta_get "$META" project) +WORKTREE_PROVIDER=$(fm_meta_get "$META" worktree_provider) +KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) +[ -n "$KIND" ] || KIND=ship +case "$WORKTREE_PROVIDER" in + '') ;; + project-command) + if [ "$BACKEND" = orca ] || [ "$KIND" = secondmate ]; then + echo "error: worktree_provider=project-command is invalid for backend=$BACKEND kind=$KIND; refusing cleanup" >&2 + exit 1 + fi + ;; + *) + echo "error: unsupported worktree provider '$WORKTREE_PROVIDER' in $META; refusing cleanup without a known removal contract" >&2 + exit 1 + ;; +esac T_ORCA= [ "$BACKEND" != orca ] || T_ORCA=$T if [ "${FM_TEARDOWN_GUARD_DONE:-0}" != 1 ]; then @@ -452,8 +476,6 @@ fi ORCA_WORKTREE_ID=$(fm_meta_get "$META" orca_worktree_id) ORCA_PATH_MATCH_VERIFIED=0 -KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) -[ -n "$KIND" ] || KIND=ship MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes PUBLIC_FOLLOWUP_HOME=$FM_HOME @@ -1056,6 +1078,24 @@ cleanup_stale_lock_for_safety_check() { return "$TEARDOWN_TREEHOUSE_LOCK_REFUSED" } +# Remove a worktree acquired by a trusted project command without guessing a +# filesystem target. The full landed/dirty/process safety sequence runs before +# this function; this final boundary additionally proves Git still registers the +# exact recorded worktree under the exact recorded primary project. +teardown_project_worktree_remove() { # + local dir=$1 project=$2 out + if ! worktree_registered_for_project "$project" "$dir"; then + echo "error: project-command worktree '$dir' is not registered to recorded project '$project'; refusing native Git removal" >&2 + return 1 + fi + if out=$(git -C "$project" worktree remove --force "$dir" 2>&1); then + [ -n "$out" ] && printf '%s\n' "$out" + return 0 + fi + [ -n "$out" ] && printf '%s\n' "$out" >&2 + return 1 +} + # 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() { @@ -2444,10 +2484,17 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then if [ "$FORCE" != "--force" ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ]; then post_lock_cleanup_check=validate_worktree_teardown_safety fi - teardown_treehouse_return "$WT" "$PROJ" "worktree" "$post_lock_cleanup_check" || { - echo "error: treehouse return failed for worktree $WT; teardown aborted" >&2 - exit 1 - } + if [ "$WORKTREE_PROVIDER" = project-command ]; then + teardown_project_worktree_remove "$WT" "$PROJ" || { + echo "error: native Git removal failed for project-command worktree $WT; teardown aborted" >&2 + exit 1 + } + else + teardown_treehouse_return "$WT" "$PROJ" "worktree" "$post_lock_cleanup_check" || { + echo "error: treehouse return failed for worktree $WT; teardown aborted" >&2 + exit 1 + } + fi fi HERDR_PRESENTATION_JOURNAL="$STATE/$ID.herdr-presentation" diff --git a/docs/architecture.md b/docs/architecture.md index afca3208d7..abb603d940 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,23 +142,25 @@ tmux, zellij, orca, and cmux expose no native busy primitive at all, so a task o That poll loop is still the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. For capable Herdr sessions, the same watcher replaces its terminal sleep with a bounded native event wait that immediately surfaces `blocked`; [Push events and polling fallback](herdr-backend.md#push-events-and-polling-fallback) owns the current mechanism and capability gates, while [runtime backend verification](verification/runtime-backends.md#native-blocked-event) owns the active evidence. The deeper session-start agent-process liveness probe is separate from that busy-state poll: tmux and Herdr have verified classifiers for secondmate recovery, Zellij remains unverified, and Orca and cmux do not support secondmate spawns. -Herdr is experimental and can be selected explicitly or by runtime auto-detection: Treehouse remains its worktree provider, [`herdr-backend.md`](herdr-backend.md) owns current setup and safety limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#herdr) owns active empirical evidence. +Herdr is experimental and can be selected explicitly or by runtime auto-detection: Treehouse is its default worktree provider, a project's local acquisition command may replace that default, [`herdr-backend.md`](herdr-backend.md) owns current setup and safety limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#herdr) owns active empirical evidence. Herdr uses one tab per task; [Watching and task containers](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, and recovery scope. Its default-on presentation projection may place one clean new task in a disposable workspace without changing endpoint authority or lifecycle ownership; [Presentation spaces](herdr-backend.md#presentation-spaces) owns that conditional design, the Herdr version floor its unconfigured default is gated behind, and its narrow home-local restored-shell cleanup at locked session start. -Zellij is experimental and selected only explicitly: Treehouse remains its worktree provider, [`zellij-backend.md`](zellij-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#zellij) owns active empirical evidence. +Zellij is experimental and selected only explicitly: Treehouse is its default worktree provider, the same project-local override applies, [`zellij-backend.md`](zellij-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#zellij) owns active empirical evidence. Zellij's container shape is simpler than herdr's: one shared `firstmate` session, one tab per task, with no per-home workspace split; visible tab titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path. Orca is experimental and selected only explicitly: Orca owns both worktree and terminal lifecycle, records `orca_worktree_id=` and `terminal=`, and removes worktrees through `orca worktree rm` only after the usual firstmate teardown checks pass. [`orca-backend.md`](orca-backend.md) owns current behavior and limitations, while [`verification/runtime-backends.md`](verification/runtime-backends.md#orca) owns active smoke evidence. -cmux is experimental, GUI-first, macOS-only, and can be selected explicitly or by runtime auto-detection from its primary `CMUX_WORKSPACE_ID` marker plus documented fallback signals: Treehouse remains its worktree provider, [`cmux-backend.md`](cmux-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#cmux) owns active source and live evidence. +cmux is experimental, GUI-first, macOS-only, and can be selected explicitly or by runtime auto-detection from its primary `CMUX_WORKSPACE_ID` marker plus documented fallback signals: Treehouse is its default worktree provider, the same project-local override applies, [`cmux-backend.md`](cmux-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#cmux) owns active source and live evidence. cmux's container shape is one workspace per task with one surface, no per-home container split; workspace titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path, and `--secondmate` spawns are refused, mirroring Orca. Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectable as a runtime backend. ## Worktrees, not branches in your checkout -Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`. +Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees by default for tmux, Herdr, Zellij, and cmux tasks, a trusted project-local command may prepare one instead, and Orca creates its own worktrees for `backend=orca`. +[`configuration.md`](configuration.md#project-worktree-acquisition) owns the local command schema and applicability boundary. For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout. -`fm-spawn.sh` also owns the base-freshness boundary for every fresh ship and scout: no worker starts until its clean task worktree matches the fetched tip of origin's resolved default branch, and any unsafe or unverifiable base stops the spawn. -Its header owns the exact refusal mechanics, while `tests/fm-spawn-pool-base-freshen.test.sh` owns the portable regression coverage. +`fm-spawn.sh` also owns the base-freshness boundary for every fresh ship and scout: no worker starts until its clean task worktree matches the fetched tip of origin's resolved default branch, and refreshing content never checks out the default branch or detaches an attached task branch. +A successful project-command task records its provider so teardown can retain the ordinary dirty and landed-work checks, verify the exact Git registration, and use native Git removal instead of Treehouse. +The `fm-spawn.sh` and `fm-teardown.sh` headers own their exact mechanics, while `tests/fm-spawn-worktree-settle.test.sh`, `tests/fm-spawn-pool-base-freshen.test.sh`, `tests/fm-control-relaunch.test.sh`, and `tests/fm-teardown.test.sh` own the portable regression coverage. The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself. Its operating checkout (`FM_ROOT`) and the disposable crewmate worktrees are all linked git worktrees of the same repository, so the valid discriminator is branch state, not whether the checkout is linked. diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 8f54d57750..a8414aa7c1 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -1,7 +1,8 @@ # cmux runtime backend cmux is an experimental macOS GUI terminal backend. -It provides task workspaces and surfaces while Treehouse continues to provide git worktrees. +It provides task workspaces and surfaces while Treehouse provides Git worktrees by default. +A trusted project-local acquisition command may prepare the worktree instead; [`configuration.md`](configuration.md#project-worktree-acquisition) owns that shared override. [`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns shared selection and metadata semantics. ## Setup @@ -87,8 +88,8 @@ A genuinely fresh surface returns an internal error from `read-screen` until som Target readiness therefore uses the structural `list-panes` response instead of a content read. Capture remains bounded and locally trimmed after `read-screen` becomes available. -`current_directory` follows a top-level shell `cd` but not the foreground subshell opened by `treehouse get`. -Spawn-time worktree discovery sends begin and end markers around `pwd`, captures the marked block, and joins wrapped path lines. +`current_directory` follows a top-level shell `cd` but not a foreground acquisition subshell such as the one opened by `treehouse get`. +Spawn-time worktree discovery sends begin and end markers around `pwd`, captures the marked block, and joins wrapped path lines for either acquisition provider. Literal send and Enter are separate calls. Enter, Escape, and Ctrl-C are supported. diff --git a/docs/configuration.md b/docs/configuration.md index 78ae19bd56..d4de53d095 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,7 +51,7 @@ The file format is unchanged in both modes; tasks-axi and manual edits produce t For spawn-capable adapters, the runtime session-provider backend controls where task windows/endpoints are created, captured, sent to, watched, and killed. `tmux` is the verified reference backend (see [`docs/tmux-backend.md`](tmux-backend.md)); `herdr`, `zellij`, `orca`, and `cmux` are experimental spawn backends (see [`docs/herdr-backend.md`](herdr-backend.md), [`docs/zellij-backend.md`](zellij-backend.md), [`docs/orca-backend.md`](orca-backend.md), and [`docs/cmux-backend.md`](cmux-backend.md)). -Treehouse remains the worktree provider for tmux, herdr, zellij, and cmux, since herdr, zellij, and cmux are session providers only; Orca provides both the task worktree and terminal endpoint. +Treehouse is the default worktree provider for tmux, herdr, zellij, and cmux, since herdr, zellij, and cmux are session providers only; a project's local acquisition command may replace that default as described under [Project worktree acquisition](#project-worktree-acquisition), while Orca always provides both the task worktree and terminal endpoint. New spawns choose the backend in this order: an explicit `--backend` flag that current authority for that exact task alone has authorized (a present captain instruction or the task's own accepted brief; never later-task precedent by analogy), then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. If more than one runtime marker is present, detection resolves innermost-first: `$TMUX` is checked before `HERDR_ENV=1`, which is checked before cmux's primary `CMUX_WORKSPACE_ID` marker and its documented fallback signals - tmux or herdr started from inside a cmux terminal is the innermost, currently-executing layer, while cmux itself (a terminal application, not a nestable multiplexer) is always checked last. See [`docs/cmux-backend.md`](cmux-backend.md#runtime-detection) for why cmux can be selected when `CMUX_WORKSPACE_ID` is absent. @@ -96,6 +96,29 @@ The caller-facing label remains `fm-`, but the actual cmux workspace title i Test cleanup must use the guarded path in [`docs/cmux-backend.md`](cmux-backend.md#current-operation-and-safety), never enumerate-and-close every workspace. `config/backend` is inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). +## Project worktree acquisition + +Treehouse remains the default acquisition and cleanup provider for fresh ship and scout work on tmux, Herdr, Zellij, and cmux. +A project that prepares worktrees itself may override fresh acquisition with one local gitignored file at `config/worktree-acquire/`, where `` is the primary project directory's basename. +The file contains exactly one non-empty shell command line and must include the literal `` placeholder at least once. +For example, a creator that does not enter its result needs this command shape: + +```sh +mkdir -p config/worktree-acquire +printf '%s\n' 'path/to/create-worktree && cd path/to/worktrees/' > config/worktree-acquire/ +``` + +The command is trusted local operator configuration and runs from the project's primary directory. +Firstmate replaces every `` only with a shell-quoted form of the already-validated task id, so the task id never becomes command syntax. +The command must leave its terminal shell in the prepared worktree when it succeeds. +Firstmate still requires two consecutive identical working-directory observations, a genuine isolated Git worktree root, a clean base refreshed to the fetched remote default tip, and the existing bounded acquisition timeout before it launches the worker. +A nonzero command result is reported promptly and preserves any existing or partly-created target for inspection instead of deleting it. +Successful custom acquisition is recorded so normal cleanup applies the same dirty-work and landed-work protections, verifies the exact worktree remains registered to the exact project, and removes it through Git's worktree interface rather than asking Treehouse to manage a worktree it did not create. +The setting never runs for a control-plane relaunch, which reuses the recorded worktree, or for a secondmate launch, which uses its seeded home. +It is also ignored for Orca because Orca owns both acquisition and cleanup. +These project-specific command files are not inherited into secondmate homes because their filesystem assumptions are home-local; configure the corresponding file in a secondmate home separately when that home's project clone needs it. +`bin/fm-spawn.sh --help` owns the exact parsing, substitution, status, timeout, metadata, and refusal mechanics. + ## Away-mode supervisor backend (FM_SUPERVISOR_BACKEND / FM_SUPERVISOR_TARGET) The `/afk` sub-supervisor injects escalation digests into firstmate's own pane independently of where new task endpoints are spawned. @@ -298,7 +321,7 @@ The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, n This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and lavish-axi cover GitHub, browser, and rich-review operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch. 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 experimental 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 experimental adapters (`herdr`, `zellij`, `cmux`) whose spawn and liveness paths parse the backend's JSON output, and the default `treehouse` worktree provider for every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`). Backend tool availability uses the adapter's own executable resolver, so bootstrap and spawn agree on supported non-`PATH` locations such as cmux's bundled CLI. An unknown resolved backend emits `BACKEND_INVALID` and blocks dispatch instead of silently dropping its dependency delta or falling back to tmux. Orca provides both the task worktree and terminal endpoint (see "Runtime backend" above), so `backend=orca` requires only `orca` on top of the universal toolchain and skips both `treehouse` and every other backend's session CLI. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 4c75fd8bc5..ec26a81f52 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -3,7 +3,8 @@ Herdr is an experimental agent-native terminal backend with native per-pane agent state and push events. Firstmate requires Herdr protocol 14 or newer; broad backend verification covers versions 0.7.1, 0.7.3, 0.7.4, 0.7.5, and 0.8.0, while protocol-16 features remain gated by availability. Default-on presentation spaces have a higher floor of Herdr 0.8.0 for the reason given under [Presentation spaces](#presentation-spaces). -Herdr provides the terminal session while Treehouse continues to provide task worktrees. +Herdr provides the terminal session while Treehouse provides task worktrees by default. +A trusted project-local acquisition command may prepare the worktree instead; [`configuration.md`](configuration.md#project-worktree-acquisition) owns that shared override. [`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns shared backend selection and metadata semantics. ## Setup diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index 63f8dec7a2..fc94c1d870 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -1,7 +1,8 @@ # Zellij runtime backend Zellij is an experimental explicit-only session backend. -It provides the terminal session while Treehouse continues to provide task worktrees. +It provides the terminal session while Treehouse provides task worktrees by default. +A trusted project-local acquisition command may prepare the worktree instead; [`configuration.md`](configuration.md#project-worktree-acquisition) owns that shared override. [`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns shared selection and metadata semantics. ## Setup @@ -66,8 +67,8 @@ A pane can still disappear between verification and the operation; downstream su Every pane operation passes an explicit `--pane-id` because a new session can focus its release-notes plugin pane, whose numeric plugin id is in a separate namespace from terminal pane ids. -`pane_cwd` follows a top-level shell `cd` but not the foreground subshell opened by `treehouse get`. -Worktree discovery therefore sends begin and end markers around `pwd`, captures the marked block, and joins wrapped path lines. +`pane_cwd` follows a top-level shell `cd` but not a foreground acquisition subshell such as the one opened by `treehouse get`. +Worktree discovery therefore sends begin and end markers around `pwd`, captures the marked block, and joins wrapped path lines for either acquisition provider. This active probe is scoped to spawn-time worktree discovery and is not advertised as a general live-cwd API. `new-tab` has no no-focus flag and temporarily focuses the created tab in attached clients. diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 9a7b4285ba..f72fc76c4f 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -775,14 +775,21 @@ test_spawn_relaunch_without_a_harness_reuses_the_recorded_one() { local dir out dir=$(new_case spawnharness rl21) add_ship_task "$dir" rl21 claude - mkdir -p "$dir/home/config" + printf 'worktree_provider=project-command\n' >> "$dir/home/state/rl21.meta" + mkdir -p "$dir/home/config/worktree-acquire" printf 'codex\n' > "$dir/home/config/crew-harness" + printf '%s\n' 'echo acquisition-must-not-run && false' \ + > "$dir/home/config/worktree-acquire/proj" printf 'zsh' > "$dir/fake/command" out=$(run_spawn "$dir" rl21 --relaunch) [ "$(meta_field "$dir" rl21 harness)" = claude ] \ || fail "fm-spawn --relaunch without --harness must reuse the recorded harness, got '$(meta_field "$dir" rl21 harness)'" + [ "$(meta_field "$dir" rl21 worktree_provider)" = project-command ] \ + || fail "fm-spawn --relaunch dropped the recorded project-command cleanup identity" assert_contains "$out" "spawned rl21 harness=claude" "the launch should report the recorded harness" - pass "fm-spawn --relaunch: with no explicit harness it reuses the task's recorded one, never the crew default" + assert_no_grep 'acquisition-must-not-run' "$dir/fake/keys" \ + "fm-spawn --relaunch reran the project's fresh worktree acquisition command" + pass "fm-spawn --relaunch: it reuses the recorded worktree and harness without rerunning fresh acquisition" } # fm-spawn arms per-task wiring on harness PREFIXES, because a task launched diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh index 8827e679d6..67c6a424ae 100755 --- a/tests/fm-spawn-pool-base-freshen.test.sh +++ b/tests/fm-spawn-pool-base-freshen.test.sh @@ -121,6 +121,28 @@ test_stale_pool_base_refreshes_before_branching() { pass "a stale pooled worktree refreshes to current origin/main before a crew branch is created" } +test_attached_task_branch_refreshes_without_losing_identity() { + local rec id out status current branch_before branch_after + id='pool-attached-branch-r2' + rec=$(make_case attached-branch "$id") + read_case_record "$rec" + git -C "$POOL_DIR" switch --quiet -c "$id" + branch_before=$(git -C "$POOL_DIR" symbolic-ref --quiet --short HEAD) \ + || fail "fixture did not attach its task branch" + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + expect_code 0 "$status" "spawn should refresh an attached task branch"$'\n'"$out" + current=$(git -C "$POOL_DIR" rev-parse origin/main) + branch_after=$(git -C "$POOL_DIR" symbolic-ref --quiet --short HEAD) \ + || fail "spawn detached the attached task branch" + [ "$branch_after" = "$branch_before" ] \ + || fail "spawn changed attached branch '$branch_before' to '$branch_after'" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$current" ] \ + || fail "spawn preserved the branch name but did not reset its content to current origin/main" + pass "base refresh resets content to the remote default tip while leaving the task branch attached" +} + test_non_main_default_branch_refreshes_before_branching() { local rec id out status current branch_head id='pool-current-trunk-r2' @@ -228,6 +250,7 @@ test_unresolved_remote_default_refuses_pool() { } test_stale_pool_base_refreshes_before_branching +test_attached_task_branch_refreshes_without_losing_identity test_non_main_default_branch_refreshes_before_branching test_direct_pr_and_scout_refresh_before_launch test_dirty_pool_refuses_without_discarding_work diff --git a/tests/fm-spawn-worktree-settle.test.sh b/tests/fm-spawn-worktree-settle.test.sh index 66f3c837af..e3667a02f4 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -12,6 +12,11 @@ # transient-then-settled pane_current_path sequence with a fake tmux and # asserts the recorded worktree resolves to the real, settled worktree, never # the stale first read. +# +# The same executable interface also covers the optional per-project acquisition +# command: safe substitution, a creator-plus-cd command that actually +# enters the prepared worktree, two-read settling after command completion, and +# an immediate preserving refusal when a retry finds the target already present. set -u # shellcheck source=tests/lib.sh @@ -141,7 +146,219 @@ test_already_settled_pane_costs_one_confirm_sleep() { pass "an already-settled pane confirms via the existing inter-poll sleep, not an extra full cycle" } +make_project_command_fakebin() { # + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) + n=0 + [ ! -f "$FM_FAKE_PANE_COUNTFILE" ] || n=$(cat "$FM_FAKE_PANE_COUNTFILE") + printf '%s\n' "$((n + 1))" > "$FM_FAKE_PANE_COUNTFILE" + cat "$FM_FAKE_PANE_PATH_FILE" + exit 0 + ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + new-window) printf '@project-command-wid\n'; exit 0 ;; + list-windows|has-session|new-session|kill-window|set-window-option) exit 0 ;; + send-keys) + printf 'tmux %s\n' "$*" >> "$FM_FAKE_TMUX_LOG" + shift + while [ $# -gt 0 ]; do + case "$1" in + -t) shift 2 ;; + -l) shift ;; + *) break ;; + esac + done + payload=${1:-} + case "$payload" in + *__fm_worktree_acquire*) + ( + cd "$FM_FAKE_PROJECT" || exit 1 + eval "$payload" + pwd -P > "$FM_FAKE_PANE_PATH_FILE" + ) + ;; + esac + exit 0 + ;; +esac +exit 0 +SH + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +printf 'unexpected treehouse %s\n' "$*" >> "$FM_FAKE_TREEHOUSE_LOG" +exit 99 +SH + chmod +x "$fakebin/tmux" "$fakebin/treehouse" + printf '%s\n' "$fakebin" +} + +make_project_command_case() { # + local name=$1 id=$2 case_dir home project prepared fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + project="$case_dir/project" + prepared="$case_dir/prepared" + fakebin=$(make_project_command_fakebin "$case_dir/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/state" \ + "$home/config/worktree-acquire" "$prepared" + printf 'codex\n' > "$home/config/crew-harness" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + touch "$home/state/.last-watcher-beat" + fm_git_init_commit "$project" + fm_git_add_origin "$project" "$case_dir/origin.git" + cat > "$project/prepare-worktree.sh" <<'SH' +#!/usr/bin/env bash +set -eu +slug=${1:?slug required} +printf '%s\n' "$slug" >> "$FM_FAKE_PREPARE_LOG" +target="../prepared/$slug" +if [ -e "$target" ]; then + echo "error: $target already exists" >&2 + exit 42 +fi +git worktree add --quiet -b "$slug" "$target" HEAD +SH + chmod +x "$project/prepare-worktree.sh" + printf '%s\n' './prepare-worktree.sh && cd ../prepared/' \ + > "$home/config/worktree-acquire/project" + printf '%s\n' "$project" > "$case_dir/pane-path" + : > "$case_dir/tmux.log" + : > "$case_dir/treehouse.log" + : > "$case_dir/prepare.log" + printf '%s\n' "$case_dir|$home|$project|$prepared|$fakebin" +} + +read_project_command_record() { + IFS='|' read -r CUSTOM_CASE CUSTOM_HOME CUSTOM_PROJECT CUSTOM_PREPARED CUSTOM_FAKEBIN < + local id=$1 + FM_ROOT_OVERRIDE='' FM_HOME="$CUSTOM_HOME" \ + FM_STATE_OVERRIDE="$CUSTOM_HOME/state" FM_DATA_OVERRIDE="$CUSTOM_HOME/data" \ + FM_PROJECTS_OVERRIDE="$CUSTOM_HOME/projects" FM_CONFIG_OVERRIDE="$CUSTOM_HOME/config" \ + FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" \ + FM_FAKE_PROJECT="$CUSTOM_PROJECT" \ + FM_FAKE_PANE_PATH_FILE="$CUSTOM_CASE/pane-path" \ + FM_FAKE_PANE_COUNTFILE="$CUSTOM_CASE/pane-count" \ + FM_FAKE_TMUX_LOG="$CUSTOM_CASE/tmux.log" \ + FM_FAKE_TREEHOUSE_LOG="$CUSTOM_CASE/treehouse.log" \ + FM_FAKE_PREPARE_LOG="$CUSTOM_CASE/prepare.log" \ + PATH="$CUSTOM_FAKEBIN:$PATH" \ + "$SPAWN" "$id" "$CUSTOM_PROJECT" --mode no-mistakes --yolo off 2>&1 +} + +test_project_command_creates_and_enters_attached_worktree() { + local rec id out status wt branch reads + id='prepared-worktree-z3' + rec=$(make_project_command_case project-command-success "$id") + read_project_command_record "$rec" + + out=$(run_project_command_spawn "$id") + status=$? + expect_code 0 "$status" "configured project acquisition should spawn successfully"$'\n'"$out" + wt="$CUSTOM_PREPARED/$id" + assert_grep "worktree=$wt" "$CUSTOM_HOME/state/$id.meta" \ + "spawn did not record the project-prepared worktree" + assert_grep 'worktree_provider=project-command' "$CUSTOM_HOME/state/$id.meta" \ + "spawn did not record the custom cleanup provider" + [ "$(cat "$CUSTOM_CASE/prepare.log")" = "$id" ] \ + || fail "the safely substituted slug did not reach the project command exactly" + assert_no_grep '' "$CUSTOM_CASE/tmux.log" \ + "the literal task placeholder reached the shell without substitution" + assert_grep "./prepare-worktree.sh '$id' && cd ../prepared/'$id'" \ + "$CUSTOM_CASE/tmux.log" \ + "the validated task slug was not shell-quoted at every placeholder" + [ ! -s "$CUSTOM_CASE/treehouse.log" ] \ + || fail "configured project acquisition unexpectedly invoked Treehouse" + branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD) \ + || fail "project-prepared worktree was detached" + [ "$branch" = "$id" ] \ + || fail "base refresh changed attached task branch '$id' to '$branch'" + reads=$(cat "$CUSTOM_CASE/pane-count") + [ "$reads" -ge 2 ] \ + || fail "configured acquisition was accepted without two working-directory reads" + pass "a project command safely substitutes the task slug, enters its prepared worktree, and preserves the attached branch" +} + +test_existing_project_target_refuses_quickly_and_preserves_work() { + local rec id out status start end elapsed target + id='prepared-existing-z4' + rec=$(make_project_command_case project-command-existing "$id") + read_project_command_record "$rec" + target="$CUSTOM_PREPARED/$id" + git -C "$CUSTOM_PROJECT" worktree add --quiet -b "$id" "$target" HEAD + printf 'unlanded work must survive\n' > "$target/preserve-me.txt" + + start=$(date +%s) + out=$(run_project_command_spawn "$id") + status=$? + end=$(date +%s) + elapsed=$((end - start)) + [ "$status" -ne 0 ] || fail "spawn succeeded despite the configured target already existing" + assert_contains "$out" "exited with status 42" \ + "spawn did not surface the project command's actionable exit status" + assert_contains "$out" "target is preserved" \ + "spawn did not state that the existing target was preserved" + [ "$elapsed" -le 5 ] \ + || fail "an already-existing target waited ${elapsed}s instead of refusing promptly" + assert_grep 'unlanded work must survive' "$target/preserve-me.txt" \ + "failed acquisition deleted or changed the existing target" + assert_absent "$CUSTOM_HOME/state/$id.meta" \ + "failed acquisition published task metadata" + [ ! -s "$CUSTOM_CASE/treehouse.log" ] \ + || fail "failed configured acquisition fell back to Treehouse" + pass "an existing project target refuses promptly and preserves its unlanded work" +} + +test_project_command_config_requires_one_placeholder_line() { + local rec id out status config + + id='prepared-invalid-placeholder-z5' + rec=$(make_project_command_case project-command-invalid-placeholder "$id") + read_project_command_record "$rec" + config="$CUSTOM_HOME/config/worktree-acquire/project" + printf '%s\n' './prepare-worktree.sh task-without-placeholder' > "$config" + out=$(run_project_command_spawn "$id") + status=$? + [ "$status" -ne 0 ] || fail "spawn accepted a project command without " + assert_contains "$out" 'must contain the literal placeholder' \ + "missing-placeholder config did not produce its schema error" + assert_absent "$CUSTOM_HOME/state/$id.meta" \ + "missing-placeholder config published task metadata" + [ ! -s "$CUSTOM_CASE/tmux.log" ] \ + || fail "missing-placeholder config reached the task shell" + + id='prepared-invalid-lines-z6' + rec=$(make_project_command_case project-command-invalid-lines "$id") + read_project_command_record "$rec" + config="$CUSTOM_HOME/config/worktree-acquire/project" + printf '%s\n' './prepare-worktree.sh ' 'cd ../prepared/' > "$config" + out=$(run_project_command_spawn "$id") + status=$? + [ "$status" -ne 0 ] || fail "spawn accepted a multi-line project acquisition config" + assert_contains "$out" 'must contain exactly one non-empty command line' \ + "multi-line config did not produce its schema error" + assert_absent "$CUSTOM_HOME/state/$id.meta" \ + "multi-line config published task metadata" + [ ! -s "$CUSTOM_CASE/tmux.log" ] \ + || fail "multi-line config reached the task shell" + pass "project acquisition config requires exactly one command line containing " +} + test_single_stale_first_read_is_not_accepted test_already_settled_pane_costs_one_confirm_sleep +test_project_command_creates_and_enters_attached_worktree +test_existing_project_target_refuses_quickly_and_preserves_work +test_project_command_config_requires_one_placeholder_line echo "# all fm-spawn-worktree-settle tests passed" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a0815a967e..87f5821096 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -579,6 +579,38 @@ test_local_only_fork_remote_allows() { pass "local-only worktree with HEAD on a fork remote is torn down (fix holds)" } +test_project_command_worktree_uses_guarded_native_git_cleanup() { + local case_dir rc + case_dir=$(make_case project-command-cleanup) + write_meta "$case_dir" local-only ship + printf 'worktree_provider=project-command\n' >> "$case_dir/state/task-x1.meta" + cat > "$case_dir/fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +printf 'unexpected treehouse return\n' >> "$FM_FAKE_TREEHOUSE_LOG" +exit 99 +SH + chmod +x "$case_dir/fakebin/treehouse" + : > "$case_dir/treehouse.log" + + set +e + FM_FAKE_TREEHOUSE_LOG="$case_dir/treehouse.log" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "project-command cleanup should succeed through native Git" + assert_absent "$case_dir/wt" \ + "project-command teardown left the registered worktree directory behind" + git -C "$case_dir/project" worktree list --porcelain \ + | grep -F "worktree $case_dir/wt" >/dev/null \ + && fail "project-command teardown left the worktree registered" + [ ! -s "$case_dir/treehouse.log" ] \ + || fail "project-command teardown incorrectly called Treehouse" + assert_absent "$case_dir/state/task-x1.meta" \ + "project-command teardown left task metadata after successful cleanup" + pass "project-command worktrees keep the standard safety checks and use guarded native Git cleanup" +} + test_teardown_prompts_tasks_axi_done_when_compatible() { local case_dir out case_dir=$(make_case tasks-axi-reminder) @@ -2592,6 +2624,7 @@ EOF } test_local_only_fork_remote_allows +test_project_command_worktree_uses_guarded_native_git_cleanup test_teardown_prompts_tasks_axi_done_when_compatible test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses From ae3bf4f2c8b1bdb0e56ca7a9d8ff160fad2bb51f Mon Sep 17 00:00:00 2001 From: Parsunic <62620332+Parsunic@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:55:58 -0400 Subject: [PATCH 2/7] no-mistakes(review): publish acquire status atomically, canonicalize settle test paths --- bin/fm-spawn.sh | 13 +++++++++---- tests/fm-spawn-worktree-settle.test.sh | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index db12b7ff1a..1163392b1c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -692,6 +692,7 @@ SPAWN_META_PUBLISH_STARTED=0 SPAWN_TASK_SET_LOCK= SPAWN_TASK_SET_LOCK_HELD=0 SPAWN_WORKTREE_ACQUIRE_STATUS= +SPAWN_WORKTREE_ACQUIRE_STATUS_TMP= WORKTREE_PROVIDER= WORKTREE_ACQUIRE_COMMAND= WORKTREE_ACQUIRE_CONFIG= @@ -811,6 +812,8 @@ spawn_abort_cleanup() { [ -z "$SPAWN_META_TMP" ] || rm -f "$SPAWN_META_TMP" 2>/dev/null || true [ -z "$SPAWN_WORKTREE_ACQUIRE_STATUS" ] \ || rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" 2>/dev/null || true + [ -z "$SPAWN_WORKTREE_ACQUIRE_STATUS_TMP" ] \ + || rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS_TMP" 2>/dev/null || true if [ "$CONFIG_INHERIT_LOCK_HELD" = 1 ]; then CONFIG_INHERIT_LOCK_HELD=0 fm_lock_release "$CONFIG_INHERIT_LOCK" || true @@ -1747,7 +1750,6 @@ resolve_worktree_acquire_command() { WORKTREE_ACQUIRE_CONFIG="$CONFIG/worktree-acquire/$project_name" if [ ! -e "$WORKTREE_ACQUIRE_CONFIG" ] && [ ! -L "$WORKTREE_ACQUIRE_CONFIG" ]; then WORKTREE_ACQUIRE_CONFIG= - WORKTREE_ACQUIRE_COMMAND='treehouse get' WORKTREE_PROVIDER= return 0 fi @@ -2314,9 +2316,11 @@ if [ "$RELAUNCH" -eq 1 ]; then elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then if [ "$WORKTREE_PROVIDER" = project-command ]; then SPAWN_WORKTREE_ACQUIRE_STATUS="$STATE/.worktree-acquire-$ID.${BASHPID:-$$}.status" - rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" + SPAWN_WORKTREE_ACQUIRE_STATUS_TMP="$SPAWN_WORKTREE_ACQUIRE_STATUS.partial" + rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" "$SPAWN_WORKTREE_ACQUIRE_STATUS_TMP" acquire_status_quoted=$(shell_quote "$SPAWN_WORKTREE_ACQUIRE_STATUS") - acquire_line="__fm_worktree_acquire() { $WORKTREE_ACQUIRE_COMMAND; }; if __fm_worktree_acquire; then __fm_worktree_acquire_rc=0; else __fm_worktree_acquire_rc=\$?; fi; unset -f __fm_worktree_acquire; printf '%s\\n' \"\$__fm_worktree_acquire_rc\" > $acquire_status_quoted; unset __fm_worktree_acquire_rc" + acquire_status_tmp_quoted=$(shell_quote "$SPAWN_WORKTREE_ACQUIRE_STATUS_TMP") + acquire_line="__fm_worktree_acquire() { $WORKTREE_ACQUIRE_COMMAND; }; if __fm_worktree_acquire; then __fm_worktree_acquire_rc=0; else __fm_worktree_acquire_rc=\$?; fi; unset -f __fm_worktree_acquire; printf '%s\\n' \"\$__fm_worktree_acquire_rc\" > $acquire_status_tmp_quoted && mv -f -- $acquire_status_tmp_quoted $acquire_status_quoted; unset __fm_worktree_acquire_rc" spawn_send_text_line "$WT_TARGET" "$acquire_line" else spawn_send_text_line "$WT_TARGET" 'treehouse get' @@ -2394,8 +2398,9 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then if [ "$WORKTREE_PROVIDER" = project-command ]; then validate_spawn_worktree "project worktree acquisition" "$T" - rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" + rm -f -- "$SPAWN_WORKTREE_ACQUIRE_STATUS" "$SPAWN_WORKTREE_ACQUIRE_STATUS_TMP" SPAWN_WORKTREE_ACQUIRE_STATUS= + SPAWN_WORKTREE_ACQUIRE_STATUS_TMP= else validate_spawn_worktree "treehouse get" "$T" fi diff --git a/tests/fm-spawn-worktree-settle.test.sh b/tests/fm-spawn-worktree-settle.test.sh index e3667a02f4..254d5d4348 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -208,6 +208,10 @@ make_project_command_case() { # fakebin=$(make_project_command_fakebin "$case_dir/fake") mkdir -p "$home/data/$id" "$home/projects" "$home/state" \ "$home/config/worktree-acquire" "$prepared" + # The fake pane reports its settled directory with `pwd -P`, so the fixture's + # expected paths must be physical too: a TMPDIR that resolves through a + # symlink (always so on macOS) would otherwise fail every path comparison. + prepared=$(cd "$prepared" && pwd -P) || return 1 printf 'codex\n' > "$home/config/crew-harness" printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" touch "$home/state/.last-watcher-beat" @@ -241,6 +245,14 @@ $1 EOF } +# fm-spawn watches a private status file to learn when the project command +# finished. Whatever the outcome, no such artifact may outlive the attempt. +assert_acquire_status_artifacts_cleaned() { # + local leftovers + leftovers=$(find "$CUSTOM_HOME/state" -maxdepth 1 -name '.worktree-acquire-*' 2>/dev/null) + [ -z "$leftovers" ] || fail "$1"$'\n'"--- left behind ---"$'\n'"$leftovers" +} + run_project_command_spawn() { # local id=$1 FM_ROOT_OVERRIDE='' FM_HOME="$CUSTOM_HOME" \ @@ -287,6 +299,8 @@ test_project_command_creates_and_enters_attached_worktree() { reads=$(cat "$CUSTOM_CASE/pane-count") [ "$reads" -ge 2 ] \ || fail "configured acquisition was accepted without two working-directory reads" + assert_acquire_status_artifacts_cleaned \ + "a successful acquisition left its private completion-status artifacts behind" pass "a project command safely substitutes the task slug, enters its prepared worktree, and preserves the attached branch" } @@ -317,6 +331,8 @@ test_existing_project_target_refuses_quickly_and_preserves_work() { "failed acquisition published task metadata" [ ! -s "$CUSTOM_CASE/treehouse.log" ] \ || fail "failed configured acquisition fell back to Treehouse" + assert_acquire_status_artifacts_cleaned \ + "an aborted acquisition left its private completion-status artifacts behind" pass "an existing project target refuses promptly and preserves its unlanded work" } From e53ded07c4643521edc2b1d4550742388edc384b Mon Sep 17 00:00:00 2001 From: Parsunic <62620332+Parsunic@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:51:19 -0400 Subject: [PATCH 3/7] no-mistakes(review): route project-command secondmate children through native Git cleanup --- bin/fm-teardown.sh | 32 ++++++++++++++++++++++++++++++-- tests/fm-teardown.test.sh | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9a52b7ceed..d124c41e37 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -45,6 +45,11 @@ # through `git -C worktree remove --force `. # An absent field keeps the existing Treehouse return path; an unknown value or # a project-command marker on Orca/secondmate metadata is refused before cleanup. +# Forced secondmate child cleanup reads the same field from each child record and +# holds it to the same contract: a project-command child worktree is removed +# through registered native Git removal, never handed to `treehouse return` and +# never rm -rf'd behind a failed return, and a child whose removal contract +# cannot be honored retains its records instead of being discarded. # A Herdr presentation journal never authorizes cleanup. Teardown still closes # only the exact task pane from ordinary endpoint metadata and never calls # `workspace close`. It retires the non-authoritative journal only when a @@ -2214,7 +2219,7 @@ preflight_firstmate_home_herdr_children() { # } 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 + 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_provider sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -2225,6 +2230,20 @@ cleanup_firstmate_home_children() { child_kind=$(meta_value "$child_meta" kind) [ -n "$child_kind" ] || child_kind=ship child_backend=$(fm_backend_of_meta "$child_meta") + child_provider=$(meta_value "$child_meta" worktree_provider) + case "$child_provider" in + '') ;; + project-command) + if [ "$child_backend" = orca ] || [ "$child_kind" = secondmate ]; then + echo "error: worktree_provider=project-command is invalid for child $child_id (backend=$child_backend kind=$child_kind); retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + fi + ;; + *) + echo "error: unsupported worktree provider '$child_provider' for child $child_id; retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + ;; + esac if [ "$child_backend" = orca ]; then child_t=$(meta_value "$child_meta" terminal) else @@ -2275,7 +2294,16 @@ cleanup_firstmate_home_children() { 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 [ "$child_provider" = project-command ]; then + if [ -z "$child_proj" ] || [ ! -d "$child_proj" ]; then + echo "error: recorded project '${child_proj:-unknown}' for project-command child $child_id is missing; retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + fi + teardown_project_worktree_remove "$child_wt" "$child_proj" || { + echo "error: native Git removal failed for project-command child worktree $child_wt; retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + } + elif [ -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 : else diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 87f5821096..8f227e90b9 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -1813,6 +1813,40 @@ test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed() { pass "forced secondmate teardown retains Herdr child identity until exact pane disappearance" } +test_forced_secondmate_project_command_child_uses_native_git_cleanup() { + local case_dir home rc child_b_wt + case_dir=$(make_case project-command-child) + write_meta "$case_dir" local-only secondmate + configure_secondmate_with_tmux_children "$case_dir" + home="$case_dir/secondmate-home" + printf 'worktree_provider=project-command\n' >> "$home/state/child-b.meta" + # git reports registrations physically, so the expected path must be too. + child_b_wt=$(cd "$case_dir/child-b-wt" && pwd -P) + : > "$case_dir/treehouse.log" + cat > "$case_dir/fakebin/treehouse" <> "$case_dir/treehouse.log" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" + + rc=0 + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + expect_code 0 "$rc" "project-command-child: forced secondmate teardown should complete" + assert_absent "$case_dir/child-b-wt" \ + "project-command-child: the project-command child worktree was not removed" + git -C "$case_dir/project" worktree list --porcelain \ + | grep -F "worktree $child_b_wt" >/dev/null \ + && fail "project-command-child: cleanup left a stale git worktree registration" + assert_no_grep "$case_dir/child-b-wt" "$case_dir/treehouse.log" \ + "project-command-child: the project-command child was handed to Treehouse" + assert_grep "$case_dir/child-a-wt" "$case_dir/treehouse.log" \ + "project-command-child: the Treehouse child stopped going through treehouse return" + assert_absent "$home" \ + "project-command-child: forced teardown left the retired secondmate home" + pass "forced secondmate cleanup removes a project-command child through registered native Git, never Treehouse" +} + configure_nested_secondmate_with_herdr_grandchild() { # local case_dir=$1 home="$1/secondmate-home" nested_home="$1/secondmate-home/nested-home" mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" @@ -2640,6 +2674,7 @@ test_herdr_flat_teardown_preflight_refuses_before_changes test_forced_secondmate_herdr_child_preflight_refuses_before_changes test_forced_secondmate_teardown_holds_descendant_lifecycle_locks test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed +test_forced_secondmate_project_command_child_uses_native_git_cleanup test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed test_herdr_projection_teardown_retires_journal_only_after_confirmed_close test_herdr_projection_teardown_retains_journal_when_close_unconfirmed From be2eed1e2509d6e1afc1b1a52798047ca6edeacd Mon Sep 17 00:00:00 2001 From: Parsunic <62620332+Parsunic@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:52:00 -0400 Subject: [PATCH 4/7] no-mistakes(review): probe Herdr cwd for project-command worktree acquisition --- bin/backends/herdr.sh | 49 +++++++++++++++++++++++++++++++++- bin/fm-spawn.sh | 4 +-- docs/herdr-backend.md | 5 ++++ tests/fm-backend-herdr.test.sh | 47 ++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 0a53fc7a77..dc2f5c0643 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2524,12 +2524,59 @@ fm_backend_herdr_target_ready() { # # `.result.pane.foreground_cwd` tracks the ACTUALLY RUNNING foreground # process's cwd instead, which is what changes when an acquisition command enters # its worktree - confirmed live against a real Treehouse acquisition. -fm_backend_herdr_current_path() { # +# +# That read answers "where is the live foreground process", which is only the +# question worth asking while an acquisition command is still holding a +# worktree subshell open. A trusted project-local acquisition command +# (docs/configuration.md "Project worktree acquisition") has the opposite +# shape: ` && cd /` runs its `cd` in the pane's OWN +# top-level shell and then EXITS, so by the time the acquisition reports +# completion there is no foreground process left to read and `cwd` is still +# frozen at pane-creation time. That provider therefore reads the shell's own +# cwd through the active marker probe below instead - the same workaround +# zellij and cmux already use for their frozen cwd fields. +fm_backend_herdr_current_path() { # [worktree-provider] + if [ "${2:-}" = project-command ]; then + fm_backend_herdr_probe_current_path "$1" + return 0 + fi 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 \ | jq -r '.result.pane.foreground_cwd // empty' 2>/dev/null } +# fm_backend_herdr_probe_current_path: the pane's own top-level shell cwd, or +# empty on any error, read by active probe rather than any pane field. Mirrors +# fm_backend_zellij_current_path / fm_backend_cmux_current_path verbatim in +# spirit: print `$PWD` between unique markers (atomically submitted, mirroring +# send_text_line), briefly settle, then capture and read only that marked +# block, joining wrapped path lines. Empty output means "no answer yet", which +# fm-spawn.sh's poll treats as not-settled, so a missed probe costs one more +# poll rather than a wrong worktree. Scoped to that worktree-discovery poll. +fm_backend_herdr_probe_current_path() { # + local target=$1 out line marker_begin="__FM_HERDR_CWD_BEGIN__" marker_end="__FM_HERDR_CWD_END__" in_block=0 chunk="" last="" + fm_backend_herdr_target_ready "$target" || return 0 + fm_backend_herdr_send_text_line "$target" "printf '%s\n' '$marker_begin'; pwd; printf '%s\n' '$marker_end'" || return 0 + sleep 0.3 + out=$(fm_backend_herdr_capture "$target" 200) || return 0 + while IFS= read -r line; do + if [ "$line" = "$marker_begin" ]; then + in_block=1 + chunk="" + continue + fi + if [ "$line" = "$marker_end" ]; then + case "$chunk" in /*) last=$chunk ;; esac + in_block=0 + continue + fi + [ "$in_block" -eq 1 ] && chunk="$chunk$line" + done < # /private/tmp) when it came from the ship/scout branch's logical `pwd` above. # Every backend's own current-path read (tmux's pane_current_path, herdr's -# foreground_cwd, zellij/cmux's active pwd probe against the live shell) can +# foreground_cwd or marker probe, zellij/cmux's active pwd probe) can # report the OS-level, physically-resolved cwd, so comparing it against a # still-symlinked PROJ_ABS can misfire both ways: false-negative (the poll # below never notices the pane left the project) or false-positive (the @@ -2214,7 +2214,7 @@ spawn_send_text_line() { # spawn_current_path() { # case "$BACKEND" in tmux) fm_backend_tmux_current_path "$1" ;; - herdr) fm_backend_herdr_current_path "$1" ;; + herdr) fm_backend_herdr_current_path "$1" "$WORKTREE_PROVIDER" ;; zellij) fm_backend_zellij_current_path "$1" "$W" ;; cmux) fm_backend_cmux_current_path "$1" "$W" ;; esac diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index ec26a81f52..09662bb913 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -28,6 +28,11 @@ A tmux pane nested inside Herdr resolves to tmux because the innermost multiplex An auto-detected Herdr spawn prints an opt-out notice. Spawn stops before creating a Herdr container or acquiring a task worktree when `herdr`, `jq`, or the protocol floor is unavailable. + +`pane get` reports `foreground_cwd` for the live foreground process, which is what Treehouse worktree discovery reads, while `cwd` stays frozen at pane creation. +A project-local acquisition command runs its `cd` in the pane's own top-level shell and then exits, leaving no foreground process to read. +Worktree discovery for that provider therefore sends begin and end markers around `pwd`, captures the marked block, and joins wrapped path lines, exactly as the Zellij and cmux backends already do. +This active probe is scoped to spawn-time worktree discovery and is not advertised as a general live-cwd API. No separate first-run provisioning is required. The required CI lane uses the pinned installers in `bin/fm-install-herdr.sh` and `bin/fm-install-treehouse.sh`. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 1adeed3645..3aef97d341 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -2939,6 +2939,51 @@ test_current_path_reads_cwd() { pass "fm_backend_herdr_current_path: reads pane foreground_cwd (the live running process), not the frozen creation-time cwd" } +test_current_path_probes_for_a_project_command_acquisition() { + local dir log resp fb out + # A trusted project-local acquisition command runs ` && cd + # /` in the pane's OWN top-level shell and then exits, so once it + # reports completion there is no foreground process left for foreground_cwd + # to describe and `cwd` is still frozen at pane-creation time. Discovery for + # that provider must instead read the shell's own cwd back from a marked + # probe, exactly as zellij and cmux do. + dir="$TMP_ROOT/cwd-project-command"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + # 1: pane run (the marked probe; silent on success) + # 2: pane read (the capture the marked cwd is read back from) + printf '%s\n' '/tmp/project' \ + '__FM_HERDR_CWD_BEGIN__' \ + '/tmp/prepared/task-slug' \ + '__FM_HERDR_CWD_END__' \ + '/tmp/prepared/task-slug $ ' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_current_path default:w1:p2 project-command' "$ROOT" ) + [ "$out" = "/tmp/prepared/task-slug" ] \ + || fail "a project-command acquisition should report the marked probe's cwd, got '$out'" + assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''run'$'\x1f''w1:p2' \ + "current_path did not submit the cwd probe into the pane" + assert_contains "$(cat "$log")" "__FM_HERDR_CWD_BEGIN__" \ + "current_path did not send the cwd begin marker" + assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''read'$'\x1f''w1:p2' \ + "current_path did not read the probe back from the pane" + assert_not_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get'$'\x1f''w1:p2' \ + "a project-command acquisition must not depend on foreground_cwd after a top-level shell cd" + pass "fm_backend_herdr_current_path: a project-command acquisition reads its cwd from an active marked probe, never foreground_cwd" +} + +test_current_path_probe_reports_nothing_without_its_marker() { + local dir log resp fb out + # No marked block yet (the probe has not rendered) must read as "no answer + # yet" so fm-spawn.sh's poll keeps waiting, never as some other pane line. + dir="$TMP_ROOT/cwd-project-command-unmarked"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '/tmp/project' '/tmp/project $ ' > "$resp/2.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_current_path default:w1:p2 project-command' "$ROOT" ) + [ -z "$out" ] || fail "an unmarked capture must report no path at all, got '$out'" + pass "fm_backend_herdr_current_path: an unrendered probe reports no path instead of an unmarked pane line" +} + # --- busy_state (semantic agent state) --------------------------------------- test_busy_state_working_maps_to_busy() { @@ -4425,6 +4470,8 @@ test_capture_preserves_pane_read_failure test_send_key_normalizes_and_targets_pane test_kill_is_best_effort test_current_path_reads_cwd +test_current_path_probes_for_a_project_command_acquisition +test_current_path_probe_reports_nothing_without_its_marker test_busy_state_working_maps_to_busy test_busy_state_done_and_blocked_map_to_idle test_busy_state_unknown_on_no_agent From 2a7dd2a547416eb5129d268890258c4cde48a4c4 Mon Sep 17 00:00:00 2001 From: Parsunic <62620332+Parsunic@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:16:35 -0400 Subject: [PATCH 5/7] no-mistakes(document): document worktree-acquire config file and provider metadata key --- AGENTS.md | 1 + docs/architecture.md | 2 +- docs/configuration.md | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd40813bf7..4c00a74697 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md +config/worktree-acquire/ optional per-project fresh-worktree acquisition command replacing treehouse get for that project's tmux, herdr, zellij, and cmux ship/scout spawns; LOCAL, gitignored, and not inherited; absent means default Treehouse acquisition and return; see docs/configuration.md "Project worktree acquisition" config/x-mode.env generated Relay watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history diff --git a/docs/architecture.md b/docs/architecture.md index abb603d940..baddff3333 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -250,7 +250,7 @@ For target project repos shipped through their own no-mistakes pipeline, commits The firstmate repo itself is the exception: its `.no-mistakes/` directory is local state, stays gitignored, and is rejected by CI if tracked. PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling `gh-axi pr merge`. The helper requires a full `https://github.com///pull/` URL, invokes `gh-axi pr merge --repo /`, defaults to `--squash`, preserves explicit merge-method flags, and rejects malformed URLs or repo override flags before recording merge state; a well-formed GitLab merge request URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) is refused too, explicitly, rather than sent to the wrong forge. -Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. +Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is released by whichever provider acquired it. [`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, and stale-lock recovery procedure. ## Optional Relay diff --git a/docs/configuration.md b/docs/configuration.md index d4de53d095..bb259e751f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,7 +113,8 @@ Firstmate replaces every `` only with a shell-quoted form of the already-v The command must leave its terminal shell in the prepared worktree when it succeeds. Firstmate still requires two consecutive identical working-directory observations, a genuine isolated Git worktree root, a clean base refreshed to the fetched remote default tip, and the existing bounded acquisition timeout before it launches the worker. A nonzero command result is reported promptly and preserves any existing or partly-created target for inspection instead of deleting it. -Successful custom acquisition is recorded so normal cleanup applies the same dirty-work and landed-work protections, verifies the exact worktree remains registered to the exact project, and removes it through Git's worktree interface rather than asking Treehouse to manage a worktree it did not create. +Successful custom acquisition records `worktree_provider=project-command` in the task's metadata, so normal cleanup applies the same dirty-work and landed-work protections, verifies the exact worktree remains registered to the exact project, and removes it through Git's worktree interface rather than asking Treehouse to manage a worktree it did not create. +An absent `worktree_provider=` means the default Treehouse acquisition and return path. The setting never runs for a control-plane relaunch, which reuses the recorded worktree, or for a secondmate launch, which uses its seeded home. It is also ignored for Orca because Orca owns both acquisition and cleanup. These project-specific command files are not inherited into secondmate homes because their filesystem assumptions are home-local; configure the corresponding file in a secondmate home separately when that home's project clone needs it. From 93caea2404c65d9f37a9618a2f41806734ad5135 Mon Sep 17 00:00:00 2001 From: Parsunic <62620332+Parsunic@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:16:30 -0400 Subject: [PATCH 6/7] no-mistakes(review): gate acquisition cwd probe on published status; preflight child provider --- bin/backends/herdr.sh | 3 ++ bin/fm-spawn.sh | 55 +++++++++++++-------- bin/fm-teardown.sh | 21 +++++++- tests/fm-spawn-worktree-settle.test.sh | 67 +++++++++++++++++++++++--- tests/fm-teardown.test.sh | 50 +++++++++++++++++++ 5 files changed, 167 insertions(+), 29 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index dc2f5c0643..7a50f1d3b7 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2553,6 +2553,9 @@ fm_backend_herdr_current_path() { # [worktree-provider] # block, joining wrapped path lines. Empty output means "no answer yet", which # fm-spawn.sh's poll treats as not-settled, so a missed probe costs one more # poll rather than a wrong worktree. Scoped to that worktree-discovery poll. +# It types into the pane, so fm-spawn.sh calls it for a project-command +# acquisition only AFTER that command's completion status is published - never +# while the operator's own command still owns the pane's foreground and stdin. fm_backend_herdr_probe_current_path() { # local target=$1 out line marker_begin="__FM_HERDR_CWD_BEGIN__" marker_end="__FM_HERDR_CWD_END__" in_block=0 chunk="" last="" fm_backend_herdr_target_ready "$target" || return 0 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index e0f35feea5..3676a520b1 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -150,6 +150,9 @@ # The command's completion status is recorded privately so an immediate # refusal (including an already-existing target) is reported without waiting # for the cwd timeout; any existing or partly-created worktree is preserved. +# No working-directory read is issued until that status is published, so a +# backend whose read is an active pane probe never types into the operator's +# still-running command. # The usual two-identical-cwd-read isolation proof, 60 one-second polls, base # refresh, and failure preservation remain unchanged. # The setting is deliberately ignored by --relaunch, --secondmate, and Orca: @@ -2346,32 +2349,44 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then # 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 project command's cwd read waits for the atomically published completion + # status first, and the poll issues NO read at all before that. The read is + # passive only on tmux: herdr's project-command read, zellij's and cmux's + # reads are ACTIVE probes that type a `pwd` line into the pane. The operator's + # acquisition command owns that pane's foreground while it runs and may read + # its own stdin, so probing it would inject text into trusted local code for a + # value this loop discards until the command reports completion anyway. candidate="" acquire_complete=0 for _ in $(seq 1 60); do - if [ "$WORKTREE_PROVIDER" = project-command ] \ - && [ -f "$SPAWN_WORKTREE_ACQUIRE_STATUS" ]; then - acquire_rc= - IFS= read -r acquire_rc < "$SPAWN_WORKTREE_ACQUIRE_STATUS" || { - echo "error: could not read completion status for project worktree acquisition; existing work is preserved. Inspect window $T" >&2 - exit 1 - } - case "$acquire_rc" in - 0) acquire_complete=1 ;; - ''|*[!0-9]*) - echo "error: project worktree acquisition returned an invalid completion status; existing work is preserved. Inspect window $T" >&2 - exit 1 - ;; - *) - echo "error: project worktree acquisition for '$PROJ_ABS' exited with status $acquire_rc before entering an isolated worktree; any existing or partly-created target is preserved. Inspect window $T, land or deliberately remove that target, then retry" >&2 + if [ "$WORKTREE_PROVIDER" = project-command ] && [ "$acquire_complete" -ne 1 ]; then + if [ -f "$SPAWN_WORKTREE_ACQUIRE_STATUS" ]; then + acquire_rc= + IFS= read -r acquire_rc < "$SPAWN_WORKTREE_ACQUIRE_STATUS" || { + echo "error: could not read completion status for project worktree acquisition; existing work is preserved. Inspect window $T" >&2 exit 1 - ;; - esac + } + case "$acquire_rc" in + 0) acquire_complete=1 ;; + ''|*[!0-9]*) + echo "error: project worktree acquisition returned an invalid completion status; existing work is preserved. Inspect window $T" >&2 + exit 1 + ;; + *) + echo "error: project worktree acquisition for '$PROJ_ABS' exited with status $acquire_rc before entering an isolated worktree; any existing or partly-created target is preserved. Inspect window $T, land or deliberately remove that target, then retry" >&2 + exit 1 + ;; + esac + fi + if [ "$acquire_complete" -ne 1 ]; then + candidate="" + sleep 1 + continue + fi fi p=$(spawn_current_path "$WT_TARGET" || true) - if [ "$WORKTREE_PROVIDER" = project-command ] && [ "$acquire_complete" -ne 1 ]; then - candidate="" - elif [ -n "$p" ]; then + if [ -n "$p" ]; then p_real=$(real_path_or_raw "$p") if [ "$p_real" != "$PROJ_ABS_REAL" ]; then if [ -n "$candidate" ] && [ "$p_real" = "$candidate" ]; then diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index d124c41e37..209ea0b97f 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -49,7 +49,10 @@ # holds it to the same contract: a project-command child worktree is removed # through registered native Git removal, never handed to `treehouse return` and # never rm -rf'd behind a failed return, and a child whose removal contract -# cannot be honored retains its records instead of being discarded. +# cannot be honored retains its records instead of being discarded. The provider +# field is schema-checked for every child and descendant by the non-destructive +# preflight, so an unknown or misplaced value refuses while every endpoint, +# worktree, and record in that home tree is still intact. # A Herdr presentation journal never authorizes cleanup. Teardown still closes # only the exact task pane from ordinary endpoint metadata and never calls # `workspace close`. It retires the non-authoritative journal only when a @@ -2052,7 +2055,7 @@ preflight_descendant_task_locks() { } validate_firstmate_home_children_removal() { - local home=$1 sub_state child_meta child_id child_wt child_proj child_kind child_home child_backend child_orca_worktree_id + local home=$1 sub_state child_meta child_id child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_provider sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -2064,6 +2067,20 @@ validate_firstmate_home_children_removal() { child_kind=$(meta_value "$child_meta" kind) [ -n "$child_kind" ] || child_kind=ship child_backend=$(fm_backend_of_meta "$child_meta") + child_provider=$(meta_value "$child_meta" worktree_provider) + case "$child_provider" in + '') ;; + project-command) + if [ "$child_backend" = orca ] || [ "$child_kind" = secondmate ]; then + echo "REFUSED: child $child_id records worktree_provider=project-command with backend=$child_backend kind=$child_kind, which has no removal contract; forced teardown changed nothing" >&2 + return 1 + fi + ;; + *) + echo "REFUSED: child $child_id records unsupported worktree provider '$child_provider'; forced teardown changed nothing" >&2 + return 1 + ;; + esac if [ "$child_kind" = secondmate ]; then child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt diff --git a/tests/fm-spawn-worktree-settle.test.sh b/tests/fm-spawn-worktree-settle.test.sh index 254d5d4348..545459395a 100755 --- a/tests/fm-spawn-worktree-settle.test.sh +++ b/tests/fm-spawn-worktree-settle.test.sh @@ -15,8 +15,9 @@ # # The same executable interface also covers the optional per-project acquisition # command: safe substitution, a creator-plus-cd command that actually -# enters the prepared worktree, two-read settling after command completion, and -# an immediate preserving refusal when a retry finds the target already present. +# enters the prepared worktree, two-read settling after command completion, no +# working-directory read at all until that completion is published, and an +# immediate preserving refusal when a retry finds the target already present. set -u # shellcheck source=tests/lib.sh @@ -157,6 +158,11 @@ case "$*" in n=0 [ ! -f "$FM_FAKE_PANE_COUNTFILE" ] || n=$(cat "$FM_FAKE_PANE_COUNTFILE") printf '%s\n' "$((n + 1))" > "$FM_FAKE_PANE_COUNTFILE" + published=no + for status_file in "$FM_FAKE_STATE"/.worktree-acquire-*.status; do + [ ! -e "$status_file" ] || published=yes + done + printf 'cwd-read acquire-status-published=%s\n' "$published" >> "$FM_FAKE_PROBE_LOG" cat "$FM_FAKE_PANE_PATH_FILE" exit 0 ;; @@ -178,11 +184,20 @@ case "${1:-}" in payload=${1:-} case "$payload" in *__fm_worktree_acquire*) - ( - cd "$FM_FAKE_PROJECT" || exit 1 - eval "$payload" - pwd -P > "$FM_FAKE_PANE_PATH_FILE" - ) + if [ -n "${FM_FAKE_ACQUIRE_DELAY:-}" ]; then + ( + sleep "$FM_FAKE_ACQUIRE_DELAY" + cd "$FM_FAKE_PROJECT" || exit 1 + eval "$payload" + pwd -P > "$FM_FAKE_PANE_PATH_FILE" + ) >/dev/null 2>&1 & + else + ( + cd "$FM_FAKE_PROJECT" || exit 1 + eval "$payload" + pwd -P > "$FM_FAKE_PANE_PATH_FILE" + ) + fi ;; esac exit 0 @@ -236,6 +251,7 @@ SH : > "$case_dir/tmux.log" : > "$case_dir/treehouse.log" : > "$case_dir/prepare.log" + : > "$case_dir/cwd-read.log" printf '%s\n' "$case_dir|$home|$project|$prepared|$fakebin" } @@ -265,6 +281,9 @@ run_project_command_spawn() { # FM_FAKE_TMUX_LOG="$CUSTOM_CASE/tmux.log" \ FM_FAKE_TREEHOUSE_LOG="$CUSTOM_CASE/treehouse.log" \ FM_FAKE_PREPARE_LOG="$CUSTOM_CASE/prepare.log" \ + FM_FAKE_PROBE_LOG="$CUSTOM_CASE/cwd-read.log" \ + FM_FAKE_STATE="$CUSTOM_HOME/state" \ + FM_FAKE_ACQUIRE_DELAY="${FM_FAKE_ACQUIRE_DELAY:-}" \ PATH="$CUSTOM_FAKEBIN:$PATH" \ "$SPAWN" "$id" "$CUSTOM_PROJECT" --mode no-mistakes --yolo off 2>&1 } @@ -336,6 +355,39 @@ test_existing_project_target_refuses_quickly_and_preserves_work() { pass "an existing project target refuses promptly and preserves its unlanded work" } +# The pane's working-directory read is an ACTIVE probe on herdr, zellij, and +# cmux: it types a `pwd` line into the very pane the acquisition command is +# running in, so a read issued while a trusted project command still owns that +# pane's foreground injects text into the operator's own code. The shared poll +# must issue no working-directory read at all until the command's completion +# status is published, and must still settle on two agreeing reads afterwards. +test_no_cwd_read_before_project_command_completion() { + local rec id out status wt reads + id='prepared-slow-z7' + rec=$(make_project_command_case project-command-slow "$id") + read_project_command_record "$rec" + + FM_FAKE_ACQUIRE_DELAY=3 + out=$(run_project_command_spawn "$id") + status=$? + FM_FAKE_ACQUIRE_DELAY= + + expect_code 0 "$status" "a slow project acquisition should still spawn successfully"$'\n'"$out" + assert_no_grep 'acquire-status-published=no' "$CUSTOM_CASE/cwd-read.log" \ + "the poll read the pane's working directory while the project command still owned that pane" + assert_grep 'acquire-status-published=yes' "$CUSTOM_CASE/cwd-read.log" \ + "the poll never read the pane's working directory after the command reported completion" + wt="$CUSTOM_PREPARED/$id" + assert_grep "worktree=$wt" "$CUSTOM_HOME/state/$id.meta" \ + "a slow project acquisition did not record its prepared worktree" + reads=$(grep -c 'cwd-read ' "$CUSTOM_CASE/cwd-read.log") + [ "$reads" -ge 2 ] \ + || fail "a slow acquisition was accepted without two working-directory reads" + assert_acquire_status_artifacts_cleaned \ + "a slow acquisition left its private completion-status artifacts behind" + pass "no working-directory read is issued until the project command publishes its completion status" +} + test_project_command_config_requires_one_placeholder_line() { local rec id out status config @@ -375,6 +427,7 @@ test_single_stale_first_read_is_not_accepted test_already_settled_pane_costs_one_confirm_sleep test_project_command_creates_and_enters_attached_worktree test_existing_project_target_refuses_quickly_and_preserves_work +test_no_cwd_read_before_project_command_completion test_project_command_config_requires_one_placeholder_line echo "# all fm-spawn-worktree-settle tests passed" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 8f227e90b9..9e43b22128 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -1847,6 +1847,55 @@ SH pass "forced secondmate cleanup removes a project-command child through registered native Git, never Treehouse" } +# Children are enumerated in name order, so an unknown provider recorded on the +# SECOND child is only a genuine pre-cleanup refusal if the FIRST child's pane, +# worktree, and records all survive it. The provider schema check therefore +# belongs in the non-destructive preflight, alongside every other child refusal. +test_forced_secondmate_unknown_child_provider_refuses_before_changes() { + local case_dir home rc child + case_dir=$(make_case unknown-child-provider) + write_meta "$case_dir" local-only secondmate + configure_secondmate_with_tmux_children "$case_dir" + home="$case_dir/secondmate-home" + printf 'worktree_provider=some-future-provider\n' >> "$home/state/child-b.meta" + : > "$case_dir/kill.log" + : > "$case_dir/treehouse.log" + cat > "$case_dir/fakebin/tmux" <> "$case_dir/kill.log" +exit 0 +SH + cat > "$case_dir/fakebin/treehouse" <> "$case_dir/treehouse.log" +exit 0 +SH + chmod +x "$case_dir/fakebin/tmux" "$case_dir/fakebin/treehouse" + + rc=0 + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] \ + || fail "unknown-child-provider: forced teardown accepted an unknown child worktree provider" + [ ! -s "$case_dir/kill.log" ] \ + || fail "unknown-child-provider: refusal killed an endpoint before the schema check" + [ ! -s "$case_dir/treehouse.log" ] \ + || fail "unknown-child-provider: refusal returned a worktree before the schema check" + for child in child-a child-b; do + [ -e "$home/state/$child.meta" ] \ + || fail "unknown-child-provider: refusal erased $child's durable record" + [ -d "$case_dir/$child-wt" ] \ + || fail "unknown-child-provider: refusal removed $child's worktree" + done + [ -d "$home" ] || fail "unknown-child-provider: refusal removed the secondmate home" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "unknown-child-provider: refusal erased the parent record" + assert_grep "unsupported worktree provider 'some-future-provider'" "$case_dir/stderr" \ + "unknown-child-provider: refusal did not name the unsupported provider" + assert_grep "forced teardown changed nothing" "$case_dir/stderr" \ + "unknown-child-provider: refusal did not explain its non-mutating boundary" + pass "an unknown child worktree provider refuses in preflight, before any child is torn down" +} + configure_nested_secondmate_with_herdr_grandchild() { # local case_dir=$1 home="$1/secondmate-home" nested_home="$1/secondmate-home/nested-home" mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" @@ -2675,6 +2724,7 @@ test_forced_secondmate_herdr_child_preflight_refuses_before_changes test_forced_secondmate_teardown_holds_descendant_lifecycle_locks test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed test_forced_secondmate_project_command_child_uses_native_git_cleanup +test_forced_secondmate_unknown_child_provider_refuses_before_changes test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed test_herdr_projection_teardown_retires_journal_only_after_confirmed_close test_herdr_projection_teardown_retains_journal_when_close_unconfirmed From 6b928b87facf53b88c7d0c40e88b4f57ec84741a Mon Sep 17 00:00:00 2001 From: Parsunic <62620332+Parsunic@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:43:29 -0400 Subject: [PATCH 7/7] no-mistakes(document): correct herdr cwd-probe placement and teardown provider wording --- docs/herdr-backend.md | 10 +++++----- docs/scripts.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 09662bb913..61337ca982 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -28,11 +28,6 @@ A tmux pane nested inside Herdr resolves to tmux because the innermost multiplex An auto-detected Herdr spawn prints an opt-out notice. Spawn stops before creating a Herdr container or acquiring a task worktree when `herdr`, `jq`, or the protocol floor is unavailable. - -`pane get` reports `foreground_cwd` for the live foreground process, which is what Treehouse worktree discovery reads, while `cwd` stays frozen at pane creation. -A project-local acquisition command runs its `cd` in the pane's own top-level shell and then exits, leaving no foreground process to read. -Worktree discovery for that provider therefore sends begin and end markers around `pwd`, captures the marked block, and joins wrapped path lines, exactly as the Zellij and cmux backends already do. -This active probe is scoped to spawn-time worktree discovery and is not advertised as a general live-cwd API. No separate first-run provisioning is required. The required CI lane uses the pinned installers in `bin/fm-install-herdr.sh` and `bin/fm-install-treehouse.sh`. @@ -233,6 +228,11 @@ The poll density bounds the residual possibility of an extremely fast complete t The capture owner requests at least 200 lines from Herdr and trims locally to the caller's bound. This generous floor is required for small composer and peek reads. +`pane get` reports `foreground_cwd` for the live foreground process, which is what Treehouse worktree discovery reads, while `cwd` stays frozen at pane creation. +A project-local acquisition command instead runs its `cd` in the pane's own top-level shell and then exits, leaving no foreground process to read. +Worktree discovery for that provider therefore sends begin and end markers around `pwd`, captures the marked block, and joins wrapped path lines, exactly as the Zellij and cmux backends already do. +This active probe is scoped to spawn-time worktree discovery and is not advertised as a general live-cwd API. + Herdr's native agent state can read idle while a harness waits on its own long foreground tool. The shared crew-state path therefore accepts a native `busy` as evidence of activity but never a native `idle` as evidence that a worker has stopped; the task's own semantic busy state (`bin/fm-busy-lib.sh`) decides that. A human-blocked permission dialog has no busy banner and still surfaces. diff --git a/docs/scripts.md b/docs/scripts.md index 484911c380..d49c4ad601 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -107,7 +107,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | | `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub URL | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | -| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | +| `fm-teardown.sh` | Fail-closed teardown: release landed ship worktrees through their recorded provider, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared Relay config, relay, and reply-threading helpers |