Skip to content
Merged
80 changes: 56 additions & 24 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
# 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
# session provider only, exactly like herdr/zellij, so it leases a treehouse
# worktree the same way tmux/herdr/zellij do. 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
Expand Down Expand Up @@ -261,6 +262,9 @@ fi
ORCA_ABORT_CLEANUP=0
ORCA_WORKTREE_ID=
ORCA_TERMINAL=
# Armed only before metadata publication, when no worker-owned work can exist;
# abort cleanup may release only the lease held by this task id.
TREEHOUSE_LEASE_ABORT_CLEANUP=0
HERDR_PROJECTION_ABORT_CLEANUP=0
HERDR_PROJECTION_ABORT_SESSION=
HERDR_PROJECTION_ABORT_TASK_PANE=
Expand Down Expand Up @@ -337,6 +341,17 @@ spawn_abort_cleanup() {
fi
fi
fi
# Release is forbidden wherever work might exist: a refused teardown may hold
# unpushed work and never reaches this path. Failed spawn before metadata
# publication is the explicit exception because no task owns recorded work;
# retaining that lease would permanently reserve a slot no task can claim.
if [ "$TREEHOUSE_LEASE_ABORT_CLEANUP" = 1 ] && [ -n "${WT:-}" ] && [ -n "${PROJ_ABS:-}" ]; then
TREEHOUSE_LEASE_ABORT_CLEANUP=0
if command -v treehouse >/dev/null 2>&1; then
( cd "$PROJ_ABS" && treehouse return --force --if-lease-holder "$ID" "$WT" ) >/dev/null 2>&1 || \
echo "warning: failed to return leased worktree $WT after aborted spawn; lease may still be held" >&2
fi
fi
if [ "$SPAWN_TASK_LOCK_HELD" = 1 ]; then
SPAWN_TASK_LOCK_HELD=0
fm_lock_release "$SPAWN_TASK_LOCK" || true
Expand Down Expand Up @@ -852,11 +867,9 @@ BRIEF_REAL="$BRIEF_DIR_REAL/$(basename "$BRIEF")"
# /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
# 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
# isolation guard refuses a spawn that never actually tangled). Canonicalize
# once here so every downstream comparison uses the same physical form
# report the OS-level, physically-resolved cwd. Canonicalize the project here,
# and the leased path when it is acquired, so exact landing confirmation and
# the isolation assertion compare the same physical spelling
# (docs/herdr-backend.md "Known gaps").
PROJ_ABS_REAL=$(cd "$PROJ_ABS" 2>/dev/null && pwd -P) || PROJ_ABS_REAL="$PROJ_ABS"

Expand Down Expand Up @@ -1202,7 +1215,7 @@ esac
# #134 robustness: only tmux needs a worktree-detection target distinct from $T -
# its rename-safe stable window id, set as WT_TARGET=$WID in the tmux branch above.
# Every other backend addresses its pane/surface by the id already in $T, so default
# WT_TARGET to $T for them (and for any future backend) - the shared treehouse-get +
# WT_TARGET to $T for them (and for any future backend) - the shared treehouse-lease +
# worktree-detection steps below must never reference an unbound WT_TARGET under set -u.
: "${WT_TARGET:=$T}"
spawn_send_text_line() { # <target> <text>
Expand Down Expand Up @@ -1293,36 +1306,52 @@ kimi_spawn_fail() { # <detail>
}

if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
spawn_send_text_line "$WT_TARGET" 'treehouse get'
# Durably lease a pool slot under this task id (same two-step shape as
# bin/fm-home-seed.sh's acquire_treehouse_home). Plain `treehouse get` only
# marks the slot in-use while a process holds it, so a parked task with
# unpushed commits still counted as available and could be handed to the next
# spawn. `get --lease` reserves the slot in persistent pool state and prints
# only the path - it does NOT open a subshell - so the worker is then sent
# into that known path and the landing wait below still confirms the pane cwd.
WT=$(cd "$PROJ_ABS" && treehouse get --lease --lease-holder "$ID") || {
echo "error: treehouse get --lease failed to lease a worktree for $ID" >&2
exit 1
}
[ -n "$WT" ] || {
echo "error: treehouse get --lease did not report a worktree path for $ID" >&2
exit 1
}
TREEHOUSE_LEASE_ABORT_CLEANUP=1
WT_EXPECTED_REAL=$(real_path_or_raw "$WT")
spawn_send_text_line "$WT_TARGET" "cd $(shell_quote "$WT")"

# Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree.
# Wait for the pane's cwd to land on the leased 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 <bad-name> falls back to the
# active client's window, which would misread firstmate's OWN pane path as the
# worktree and tangle a hook into the primary checkout. The window id never lies.
# Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project
# prefix would otherwise make the pane's OS-level cwd read differ from
# PROJ_ABS on the very first poll, before the pane has actually moved.
# Compare against the known leased path (physical), not merely "differs from the
# project": the path is known before the pane moves, so the wait confirms that
# exact destination rather than discovering an arbitrary non-project cwd.
#
# A single read that already differs from PROJ_ABS_REAL is not proof the pane
# A single read that already matches the leased path is not proof the pane
# settled there: on some tmux/WSL setups a brand-new window's pane_current_path
# transiently reports an unrelated stale path (seen live as another real git
# checkout entirely) before the shell catches up with treehouse get's cd. That
# stale path still passes the PROJ_ABS_REAL comparison and validate_spawn_worktree
# below (it resolves to a real, distinct worktree top-level too), so accepting it
# on one read alone silently records the wrong worktree= in state/<id>.meta. Require
# two consecutive reads to agree on the same non-project path before accepting it;
# a mismatch just becomes the new candidate rather than resetting the wait, so a
# checkout entirely) before the shell catches up with the cd. That stale path
# must not be accepted even when it is a real worktree, so require two
# consecutive reads to agree on the leased path before accepting it; a
# mismatch just becomes the new candidate rather than resetting the wait, so a
# pane that is already settled by the first real read only costs the one existing
# inter-poll sleep as confirmation, not a whole extra cycle on top.
candidate=""
landed=""
for _ in $(seq 1 60); do
p=$(spawn_current_path "$WT_TARGET" || true)
if [ -n "$p" ]; then
p_real=$(real_path_or_raw "$p")
if [ "$p_real" != "$PROJ_ABS_REAL" ]; then
if [ "$p_real" = "$WT_EXPECTED_REAL" ]; then
if [ -n "$candidate" ] && [ "$p_real" = "$candidate" ]; then
WT="$p"
landed=$p
break
fi
candidate="$p_real"
Expand All @@ -1334,12 +1363,13 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
fi
sleep 1
done
if [ -z "$WT" ]; then
echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2
if [ -z "$landed" ]; then
echo "error: worker did not enter leased worktree $WT within 60s; inspect window $T" >&2
exit 1
fi
WT=$landed

validate_spawn_worktree "treehouse get" "$T"
validate_spawn_worktree "treehouse lease" "$T"
fi

# Per-task temp root: /tmp/fm-<id>/ with Go's build temp nested at gotmp/. Go won't
Expand Down Expand Up @@ -1644,6 +1674,8 @@ META_WINDOW=$T
fi
} > "$STATE/$ID.meta"
[ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0
# Metadata now owns the leased worktree; teardown (not abort cleanup) returns it.
TREEHOUSE_LEASE_ABORT_CLEANUP=0

sq_brief=$(shell_quote "$BRIEF")
sq_turnend=$(shell_quote "$TURNEND")
Expand Down
105 changes: 57 additions & 48 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@
# is the approved discard path that prevalidates child removal targets, discards
# child work, kills child runtime endpoints, and removes the retired home. Removing a
# leased home releases its durable treehouse lease so the pool slot is freed,
# never left leased forever. If the treehouse return fails, teardown leaves the
# leased home and state in place instead of hiding a still-held lease.
# never left leased forever. Ship and scout worktrees leased at spawn are
# released the same way: only a successful `treehouse return` drops the lease.
# A REFUSED teardown (uncommitted or unlanded work) never reaches return, so the
# lease stays held and protects the slot. If the treehouse return fails, teardown
# leaves the leased path and state in place instead of hiding a still-held lease.
# Usage: fm-teardown.sh <task-id> [--force]
# --force skips ordinary-task dirty and landed-work checks, skips scout report
# checks, and discards secondmate child work for kind=secondmate. Only use it
Expand Down Expand Up @@ -1402,12 +1405,17 @@ if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then
fi

# A Herdr close may reposition shared workspace order, so the whole
# destructive sequence below (worktree return, pane close, record removal)
# destructive sequence below (pane close, worktree return, record removal)
# runs under the named-session presentation lock, acquired BEFORE anything is
# returned or erased: a contended lock refuses here while the isolated copy,
# every durable record, and the endpoint are all still intact for a plain
# rerun. An unresolvable lock path (for example an unreachable server) also
# refuses before any destructive step.
#
# For Herdr, the pane is closed under that lock BEFORE treehouse return.
# Leased ship/scout workers have their shell cwd in the worktree; returning
# first would kill that shell via pane-death and steal captain focus without
# the focus-preserving close path.
TEARDOWN_HERDR_SESSION=
TEARDOWN_HERDR_PANE=
if [ "$BACKEND" = herdr ]; then
Expand All @@ -1417,49 +1425,6 @@ if [ "$BACKEND" = herdr ]; then
TEARDOWN_HERDR_PANE=$FM_BACKEND_HERDR_PANE
fi

# Best-effort: drop the local task branch so the shared repo does not accumulate refs.
if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then
require_orca_worktree_path_match_if_present "$ORCA_WORKTREE_ID" "$WT" || exit 1
ORCA_PATH_MATCH_VERIFIED=1
fi
if [ -d "$WT" ]; then
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true
fi
fi
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.opencode/plugins/fm-busy-state.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend"
fi
[ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true
fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID"
elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true
fi
fi
# Remove our hook file so a reused pool worktree cannot fire signals for a dead task.
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend"
# Kills remaining processes in the worktree (including the agent), resets, returns
# to pool. treehouse resolves the pool from the working directory, so run it from
# the project. teardown_treehouse_return tolerates transient and stale git locks
# left by a killed crew process; see the script header for retry and stale-lock proof.
post_lock_cleanup_check=
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
}
fi

HERDR_PRESENTATION_JOURNAL="$STATE/$ID.herdr-presentation"
HERDR_PRESENTATION_RETIRE_CANDIDATE=0
HERDR_PRESENTATION_SESSION=
Expand All @@ -1482,8 +1447,7 @@ if [ "$BACKEND" = herdr ] \
fi

if [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then
# The presentation lock was acquired before the worktree return above; a
# contended lock already refused this teardown while everything was intact.
# Contended lock already refused in preflight while everything was intact.
if teardown_herdr_session_lock_held "$HERDR_PRESENTATION_SESSION"; then
fm_backend_herdr_projection_close_pane_focus_preserving \
"$HERDR_PRESENTATION_SESSION" "$HERDR_PRESENTATION_PANE" 2>/dev/null || true
Expand Down Expand Up @@ -1526,6 +1490,51 @@ if [ "$BACKEND" = herdr ]; then
exit 1
fi
fi

# Best-effort: drop the local task branch so the shared repo does not accumulate refs.
# Herdr panes are already closed above so treehouse return cannot kill the
# worker shell via pane-death and steal captain focus.
if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then
require_orca_worktree_path_match_if_present "$ORCA_WORKTREE_ID" "$WT" || exit 1
ORCA_PATH_MATCH_VERIFIED=1
fi
if [ -d "$WT" ]; then
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true
fi
fi
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.opencode/plugins/fm-busy-state.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend"
fi
[ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true
fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID"
elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true
fi
fi
# Remove our hook file so a reused pool worktree cannot fire signals for a dead task.
rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \
"$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend"
# Kills remaining processes in the worktree (including the agent), resets, returns
# to pool. treehouse resolves the pool from the working directory, so run it from
# the project. teardown_treehouse_return tolerates transient and stale git locks
# left by a killed crew process; see the script header for retry and stale-lock proof.
post_lock_cleanup_check=
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; lease may still be held" >&2
exit 1
}
fi
if [ "$KIND" = secondmate ]; then
[ -n "$HOME_PATH" ] || HOME_PATH=$WT
remove_firstmate_home "$HOME_PATH" "secondmate home" "$ID"
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectab
## 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`.
Treehouse-backed ship and scout spawns durably lease the chosen pool slot under the task id before sending the worker into its known path.
After task metadata is published, the lease survives parked or idle workers and is released only by a successful `treehouse return` during teardown; a refused teardown or failed return preserves both the worktree and its lease.
A spawn that aborts before publishing task metadata releases only a lease whose holder still matches that task id, because no worker-owned task state can yet depend on the slot.
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.

The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself.
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-backend-autodetect-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ herdr_forget_inherited_pane
# real-herdr smoke fixture free of unrelated OS symlink noise.
# The old fm-spawn bug that originally motivated this fixture shape was fixed in
# fm-spawn-symlink-guard-s8: fm-spawn.sh now normalizes PROJ_ABS and observed
# backend cwd reads before the worktree-discovery comparison.
# backend cwd reads before the leased-worktree landing comparison.
# The dedicated regression is
# tests/fm-backend.test.sh:test_spawn_symlinked_project_prefix_avoids_false_refusal.
TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-backend-autodetect-smoke.XXXXXX")
Expand Down
9 changes: 4 additions & 5 deletions tests/fm-backend-cmux-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,12 @@ case "$p" in
esac
pass "real cmux: current_path reads the surface's live cwd after a direct cd"

# The load-bearing case: a NESTED SUBSHELL's own cd (exactly what `treehouse
# get` does). Verified real finding (docs/cmux-backend.md finding #2):
# The nested-subshell compatibility case. Verified real finding
# (docs/cmux-backend.md finding #2):
# current_directory stays frozen at wherever the surface's shell was when it
# launched the subshell as a foreground command - it never follows the
# subshell's own cd. fm_backend_cmux_current_path's active pwd-probe is what
# fm-spawn.sh's worktree-discovery poll actually depends on, so this must be
# proven against a real subshell, not just a plain cd in the top-level shell.
# subshell's own cd. fm_backend_cmux_current_path's active pwd-probe must keep
# reporting the live shell path, not just a plain cd in the top-level shell.
fm_backend_cmux_send_text_line "$TARGET" 'cd / && bash'
sleep 0.5
fm_backend_cmux_send_text_line "$TARGET" "cd /private/tmp"
Expand Down
Loading
Loading