Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@
# Before a secondmate launch, the home is locally fast-forwarded to the primary
# 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.
# git worktree root distinct from the primary project checkout and its
# absolute Git common directory matches the requested project's absolute Git
# common directory. A common-directory mismatch is refused before worker
# launch and preserved in place because neither Treehouse nor Orca exposes a
# supported non-cleaning quarantine or return operation; the error identifies
# the project family, allocated family, local copy, and endpoint for guarded
# reconciliation.
# 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.
# An unreachable origin, unresolved default branch, or non-clean worktree
Expand Down Expand Up @@ -1833,8 +1839,29 @@ real_path_or_raw() { # <path>
# herdr-sm-spaces-k4). Both branches converge on the same $T ("target") string
# that every downstream operation (send/capture/kill) already treats as opaque
# per-backend routing (fm_backend_resolve_selector).
resolve_absolute_git_common_dir() { # <repository>
local repository=$1 common
common=$(git -C "$repository" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
case "$common" in
/*) ;;
*) return 1 ;;
esac
CDPATH='' cd -- "$common" 2>/dev/null && pwd -P
}

preserve_common_dir_mismatch_allocation() {
# Orca's only removal primitive is forced, and Treehouse documents no
# non-cleaning quarantine or return operation. Herdr presentation abort and
# Orca abort cleanup would otherwise remove the endpoint or allocated local
# copy on EXIT, so disable only those cleanup paths for this safety refusal.
# The ordinary tmux, flat-Herdr, Zellij, and cmux abort paths already retain
# their endpoint and Treehouse allocation in place.
HERDR_PROJECTION_ABORT_CLEANUP=0
ORCA_ABORT_CLEANUP=0
}

validate_spawn_worktree() { # <source> <inspect-target>
local source=$1 inspect_target=$2 wt_real proj_real wt_top wt_top_real
local source=$1 inspect_target=$2 wt_real proj_real wt_top wt_top_real proj_common wt_common
wt_real=
if ! wt_real=$(cd "$WT" 2>/dev/null && pwd -P); then
wt_real=
Expand All @@ -1849,6 +1876,21 @@ validate_spawn_worktree() { # <source> <inspect-target>
echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${wt_top:-none}'; primary '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2
exit 1
fi
if ! proj_common=$(resolve_absolute_git_common_dir "$proj_real"); then
preserve_common_dir_mismatch_allocation
echo "error: $source allocated '$wt_real', but the requested project's absolute Git common directory could not be resolved from '$proj_real'; refusing before worker launch. No supported non-cleaning allocation return or quarantine operation is available, so the allocated local copy and any existing endpoint are preserved in place. Inspect $inspect_target and reconcile the pool or provider through its guarded ownership path before retrying" >&2
exit 1
fi
if ! wt_common=$(resolve_absolute_git_common_dir "$wt_real"); then
preserve_common_dir_mismatch_allocation
echo "error: $source allocated '$wt_real', but its absolute Git common directory could not be resolved; the requested project '$proj_real' uses '$proj_common'. Refusing before worker launch. No supported non-cleaning allocation return or quarantine operation is available, so the allocated local copy and any existing endpoint are preserved in place. Inspect $inspect_target and reconcile the pool or provider through its guarded ownership path before retrying" >&2
exit 1
fi
if [ "$wt_common" != "$proj_common" ]; then
preserve_common_dir_mismatch_allocation
echo "error: Git common-directory mismatch after $source: requested project '$proj_real' uses '$proj_common', but allocated worktree '$wt_real' uses '$wt_common'. Refusing before worker launch. No supported non-cleaning allocation return or quarantine operation is available, so the allocated local copy and any existing endpoint are preserved in place. Inspect $inspect_target and reconcile the mixed clone-family pool or provider through its guarded ownership path before retrying; do not force-return, clean, reset, or remove this allocation" >&2
exit 1
fi
}

# A pooled slot whose only deviation is a submodule gitlink is stale, not dirty:
Expand Down Expand Up @@ -2290,7 +2332,9 @@ EOF
echo "error: orca did not return a worktree id/path for $W" >&2
exit 1
fi
validate_spawn_worktree "orca worktree create" "$W"
ORCA_INSPECT_TARGET="task '$W', Orca worktree id '$ORCA_WORKTREE_ID'"
[ -z "$ORCA_TERMINAL" ] || ORCA_INSPECT_TARGET="$ORCA_INSPECT_TARGET, terminal '$ORCA_TERMINAL'"
validate_spawn_worktree "orca worktree create" "$ORCA_INSPECT_TARGET"
if [ -z "$ORCA_TERMINAL" ]; then
ORCA_TERMINAL=$(fm_backend_orca_terminal_create "$ORCA_WORKTREE_ID" "$W") || exit 1
fi
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,8 @@ family_for_basename() {
fm-control.test.sh|fm-control-relaunch.test.sh|\
fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|\
fm-send-inbox.test.sh|fm-spawn-batch.test.sh|\
fm-spawn-dispatch-profile.test.sh|\
fm-spawn-common-dir.test.sh|fm-spawn-dispatch-profile.test.sh|\
fm-spawn-pool-base-freshen.test.sh|\
fm-trace-context-spawn.test.sh|fm-spawn-worktree-settle.test.sh|\
fm-teardown-endpoint-safety.test.sh)
printf '%s\n' backend-dispatch
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,11 @@ 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`.
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.
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 and belongs to the requested project's absolute Git common directory.
The common-directory check is the single post-allocation owner shared by Treehouse-backed tmux, Herdr, Zellij, and cmux tasks and by Orca-owned task worktrees.
A mismatched allocation is refused before worker launch and preserved in place because neither provider exposes a supported non-cleaning quarantine or return operation.
`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.
Its header owns the exact refusal mechanics, `tests/fm-spawn-common-dir.test.sh` owns portable mixed-clone preservation coverage, and `tests/fm-spawn-pool-base-freshen.test.sh` owns portable base-freshness 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.
Expand Down
86 changes: 86 additions & 0 deletions tests/fm-backend-orca.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,90 @@ test_spawn_refuses_orca_nonisolated_worktree() {
pass "fm-spawn.sh --backend orca: refuses non-isolated worktrees and closes implicit terminals"
}

test_spawn_preserves_orca_common_dir_mismatch() {
local proj foreign wt data state config id out status before
id="orcamixedfamilyz5"
proj="$TMP_ROOT/mixed-orca-project"
foreign="$TMP_ROOT/mixed-orca-foreign"
wt="$TMP_ROOT/mixed-orca-wt"
data="$TMP_ROOT/mixed-orca-data"
state="$TMP_ROOT/mixed-orca-state"
config="$TMP_ROOT/mixed-orca-config"
fm_git_init_commit "$proj"
fm_git_worktree "$foreign" "$wt" "fm/$id"
mkdir -p "$data/$id" "$state" "$config"
printf 'brief\n' > "$data/$id/brief.md"
touch "$state/.last-watcher-beat"
before=$(git -C "$wt" rev-parse HEAD)
orca_case mixed-family-spawn
printf '1\n' > "$RESP/1.exit"
printf '{"ok":true,"result":{"repo":{"id":"repo-mixed-family"}}}\n' > "$RESP/2.out"
printf '{"ok":true,"result":{"worktree":{"id":"wt-mixed-family","path":"%s"},"terminal":{"handle":"term-mixed-family"}}}\n' "$wt" > "$RESP/3.out"
out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \
FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \
FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \
"$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --mode no-mistakes --yolo off --backend orca 2>&1 )
status=$?
expect_code 1 "$status" "fm-spawn.sh --backend orca should refuse a foreign clone-family worktree"
assert_contains "$out" "Git common-directory mismatch after orca worktree create" \
"Orca spawn did not use the shared clone-family guard"
assert_contains "$out" "Orca worktree id 'wt-mixed-family', terminal 'term-mixed-family'" \
"Orca mismatch refusal did not retain precise allocation identifiers"
assert_contains "$out" "preserved in place" \
"Orca mismatch refusal did not explain that the allocation was retained"
[ "$(git -C "$wt" rev-parse HEAD)" = "$before" ] \
|| fail "Orca mismatch refusal changed the allocated worktree HEAD"
assert_absent "$state/$id.meta" "Orca mismatch refusal must not publish launched-task metadata"
assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''send' \
"Orca mismatch refusal sent a worker launch command"
assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \
"Orca mismatch refusal closed the preserved endpoint"
assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \
"Orca mismatch refusal used the provider's forced removal path"
pass "fm-spawn.sh --backend orca: preserves a foreign clone-family allocation before worker launch"
}

test_spawn_preserves_terminal_less_orca_common_dir_mismatch() {
local proj foreign wt data state config id out status before
id="orcamixednotermz6"
proj="$TMP_ROOT/mixed-orca-no-terminal-project"
foreign="$TMP_ROOT/mixed-orca-no-terminal-foreign"
wt="$TMP_ROOT/mixed-orca-no-terminal-wt"
data="$TMP_ROOT/mixed-orca-no-terminal-data"
state="$TMP_ROOT/mixed-orca-no-terminal-state"
config="$TMP_ROOT/mixed-orca-no-terminal-config"
fm_git_init_commit "$proj"
fm_git_worktree "$foreign" "$wt" "fm/$id"
mkdir -p "$data/$id" "$state" "$config"
printf 'brief\n' > "$data/$id/brief.md"
touch "$state/.last-watcher-beat"
before=$(git -C "$wt" rev-parse HEAD)
orca_case mixed-family-no-terminal-spawn
printf '1\n' > "$RESP/1.exit"
printf '{"ok":true,"result":{"repo":{"id":"repo-mixed-no-terminal"}}}\n' > "$RESP/2.out"
printf '{"ok":true,"result":{"worktree":{"id":"wt-mixed-no-terminal","path":"%s"}}}\n' "$wt" > "$RESP/3.out"
out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \
FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \
FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \
"$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --mode no-mistakes --yolo off --backend orca 2>&1 )
status=$?
expect_code 1 "$status" "fm-spawn.sh --backend orca should refuse a terminal-less foreign clone-family worktree"
assert_contains "$out" "task 'fm-$id', Orca worktree id 'wt-mixed-no-terminal'" \
"terminal-less Orca mismatch refusal did not retain available allocation identifiers"
assert_contains "$out" "allocated local copy and any existing endpoint are preserved in place" \
"terminal-less Orca mismatch refusal claimed a nonexistent endpoint was preserved"
assert_not_contains "$out" "terminal '" \
"terminal-less Orca mismatch refusal invented a terminal identifier"
[ "$(git -C "$wt" rev-parse HEAD)" = "$before" ] \
|| fail "terminal-less Orca mismatch refusal changed the allocated worktree HEAD"
assert_absent "$state/$id.meta" "terminal-less Orca mismatch refusal must not publish launched-task metadata"
assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal' \
"terminal-less Orca mismatch refusal created, launched, or closed a terminal"
assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \
"terminal-less Orca mismatch refusal used the provider's forced removal path"
pass "fm-spawn.sh --backend orca: preserves a terminal-less foreign allocation before worker launch"
}

test_spawn_removes_orca_worktree_when_terminal_create_fails() {
local proj wt data state config id out status
id="orcatermfailz8"
Expand Down Expand Up @@ -1335,6 +1419,8 @@ test_spawn_writes_orca_metadata_and_launches_harness
test_spawn_refuses_orca_secondmate_before_home_mutation
test_spawn_refuses_orca_when_runtime_not_ready
test_spawn_refuses_orca_nonisolated_worktree
test_spawn_preserves_orca_common_dir_mismatch
test_spawn_preserves_terminal_less_orca_common_dir_mismatch
test_spawn_removes_orca_worktree_when_terminal_create_fails
test_spawn_preserves_orca_metadata_when_abort_cleanup_fails
test_spawn_releases_orca_resources_when_metadata_write_fails
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-control-relaunch.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ test_relaunch_serializes_concurrent_durable_metadata_publication() {
FM_FAKE_TRACE_RELEASE="$launch_release" \
run_control "$dir" rl28 relaunch --note "continue after publication" > "$dir/control.out" &
control_pid=$!
while [ ! -e "$prepare" ] && [ "$i" -lt 200 ]; do
while [ ! -e "$prepare" ] && [ "$i" -lt 500 ]; do
/bin/sleep 0.01
i=$((i + 1))
done
Expand Down
7 changes: 5 additions & 2 deletions tests/fm-spawn-batch.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ set -u

SPAWN="$ROOT/bin/fm-spawn.sh"
TMP_ROOT=$(fm_test_tmproot fm-spawn-batch)
mkdir -p "$TMP_ROOT/config"
export FM_BACKEND=tmux

# Clear ambient firstmate overrides so the behavior test owns its environment.
# Clear ambient firstmate overrides and use an empty config directory so the
# behavior test owns its environment even when the invoking worktree has a
# local crew-dispatch profile.
run_spawn() {
FM_ROOT_OVERRIDE='' \
FM_HOME='' \
FM_STATE_OVERRIDE='' \
FM_DATA_OVERRIDE='' \
FM_PROJECTS_OVERRIDE='' \
FM_CONFIG_OVERRIDE='' \
FM_CONFIG_OVERRIDE="$TMP_ROOT/config" \
FM_SPAWN_NO_GUARD=1 \
"$SPAWN" "$@" 2>&1
}
Expand Down
Loading
Loading