diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index dc9307de4..388e6830f 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -128,10 +128,36 @@ fm_backend_orca_repo_ensure() { # printf '%s' "$repo_id" } -fm_backend_orca_worktree_create() { # - local project=$1 name=$2 repo_id out wt_id wt_path terminal +# fm_backend_orca_parent_selector: resolve a trustworthy parent worktree +# selector from the environment when firstmate itself runs under Orca. Orca +# sets ORCA_WORKTREE_ID to the caller's own worktree (the captain's session) +# in the repo-qualified form ::. A firstmate delegate is by +# definition a child of that session, so it is the correct parent lineage +# target. Returns the id:-prefixed selector on stdout; returns 1 (silently) +# when no trustworthy parent can be discovered so the caller falls back to +# --no-parent. Never guesses a parent from unrelated worktrees. +fm_backend_orca_parent_selector() { + [ -n "${ORCA_WORKTREE_ID:-}" ] || return 1 + case "$ORCA_WORKTREE_ID" in + *'::'*) : ;; + *) return 1 ;; + esac + printf 'id:%s' "$ORCA_WORKTREE_ID" +} + +fm_backend_orca_worktree_create() { # [parent-selector] + local project=$1 name=$2 parent=${3:-} repo_id out wt_id wt_path terminal repo_id=$(fm_backend_orca_repo_ensure "$project") || return 1 - out=$(orca worktree create --repo "id:$repo_id" --name "$name" --no-parent --setup skip --json) || return 1 + [ -n "$parent" ] || parent=$(fm_backend_orca_parent_selector 2>/dev/null || true) + if [ -n "$parent" ]; then + out=$(orca worktree create --repo "id:$repo_id" --name "$name" --parent-worktree "$parent" --setup skip --json 2>/dev/null) || { + # Parent linkage is best-effort: a stale or deleted parent is not a + # spawn blocker, so retry without a parent rather than failing. + out=$(orca worktree create --repo "id:$repo_id" --name "$name" --no-parent --setup skip --json) || return 1 + } + else + out=$(orca worktree create --repo "id:$repo_id" --name "$name" --no-parent --setup skip --json) || return 1 + fi wt_id=$(printf '%s' "$out" | fm_backend_orca_json_get worktree-id) || { echo "error: orca worktree create did not return a worktree id for $name" >&2 return 1 diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index d38bd9f06..f6f621aeb 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -410,10 +410,24 @@ fm_backend_of_selector() { # printf 'tmux' } +# fm_alias_for_id: the stable fm- window/tab label for a task. Idempotent: +# an id already beginning with fm- is returned unchanged so fm-on-firstmate +# task ids (e.g. fm-orca-fix-k2) do not become fm-fm-orca-fix-k2 in worktree +# names, terminal titles, or window= metadata. The single source of truth for +# the fm- prefix; every call site that derives a window label from a task id +# must go through here so spawn, expected-label resolution, crew-state, and +# teardown all agree. +fm_alias_for_id() { # + case "$1" in + fm-*) printf '%s' "$1" ;; + *) printf 'fm-%s' "$1" ;; + esac +} + fm_backend_expected_label_of_selector() { # local raw=$1 state=$2 id id=$(fm_backend_task_id_for_selector "$raw" "$state" 2>/dev/null || true) - [ -n "$id" ] && printf 'fm-%s' "$id" + [ -n "$id" ] && fm_alias_for_id "$id" return 0 } diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index ecafc5299..a95dfa616 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -134,7 +134,7 @@ LOG_VERB=$(status_line_verb "$LOG_LINE") # herdr task is read through fm_backend_capture instead of a bare tmux probe. TASK_BACKEND=$(fm_backend_of_meta "$META") BACKEND_TARGET=$(fm_backend_target_of_meta "$META") -EXPECTED_LABEL="fm-$ID" +EXPECTED_LABEL=$(fm_alias_for_id "$ID") pane_readable() { # case "$TASK_BACKEND" in tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;; diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index ae290020c..9d86fc9fd 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -24,6 +24,9 @@ # default branch, so the fast-forward advances HEAD only and never moves the # shared default branch or any other worktree's checkout. +# shellcheck source=bin/fm-backend.sh +. "$(dirname -- "${BASH_SOURCE[0]}")/fm-backend.sh" + SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}" # --- helpers --------------------------------------------------------------- @@ -409,7 +412,7 @@ process_secondmate() { if [ "$nudge_requires_instr" = yes ] && [ -z "$FF_INSTR" ]; then return 0 fi - FF_NUDGE_WINDOWS="$FF_NUDGE_WINDOWS fm-$id" + FF_NUDGE_WINDOWS="$FF_NUDGE_WINDOWS $(fm_alias_for_id "$id")" fi } diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index a8b83c669..d5e2bc956 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -421,7 +421,7 @@ task_json_lines() { endpoint_exists=null if [ -n "$target" ]; then - if fm_backend_target_exists "$backend" "$target" "fm-$id" >/dev/null 2>&1; then + if fm_backend_target_exists "$backend" "$target" "$(fm_alias_for_id "$id")" >/dev/null 2>&1; then endpoint_exists=true else endpoint_exists=false @@ -853,7 +853,7 @@ terminal_evidence_json() { # terminal_evidence:$terminal,contradiction:$contradiction}') else if [ -n "$event_raw" ]; then - provenance=parent-event-fallback - freshness=historical-event + provenance="parent-event-fallback" + freshness="historical-event" else provenance=unknown freshness=unknown diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 418df43f1..eb1333ce2 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -249,7 +249,7 @@ for meta in "$STATE"/*.meta; do target=$(fm_backend_target_of_meta "$meta") if [ -n "$window" ]; then backend=$(fm_backend_of_meta "$meta") - if fm_backend_target_exists "$backend" "${target:-$window}" "fm-$id"; then + if fm_backend_target_exists "$backend" "${target:-$window}" "$(fm_alias_for_id "$id")"; then printf 'endpoint: alive (backend=%s window=%s)\n' "$backend" "$window" else printf 'endpoint: dead (backend=%s window=%s)\n' "$backend" "$window" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a4ec0890c..d9f415769 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -186,6 +186,7 @@ if [ "$BACKEND" = cmux ] && [ "$KIND" = secondmate ]; then fi if [ "$BACKEND" = orca ]; then fm_backend_orca_runtime_check || exit 1 + ORCA_PARENT_SELECTOR=$(fm_backend_orca_parent_selector 2>/dev/null || true) fi ORCA_ABORT_CLEANUP=0 ORCA_WORKTREE_ID= @@ -692,7 +693,7 @@ validate_spawn_worktree() { # fi } -W="fm-$ID" +W=$(fm_alias_for_id "$ID") case "$BACKEND" in tmux) SES=$(fm_backend_tmux_container_ensure) @@ -769,7 +770,7 @@ EOF ;; orca) set +e - ORCA_WT_RAW=$(fm_backend_orca_worktree_create "$PROJ_ABS" "$W") + ORCA_WT_RAW=$(fm_backend_orca_worktree_create "$PROJ_ABS" "$W" "$ORCA_PARENT_SELECTOR") ORCA_WT_STATUS=$? set -e if [ "$ORCA_WT_STATUS" -ne 0 ]; then diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index f9dc9c192..eeac8ec18 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -582,7 +582,7 @@ task_window_backend() { # stale_window_is_busy() { # local win=$1 state=$2 backend label tail40 bs backend=$(task_window_backend "$win" "$state") - label="fm-$(window_to_task "$win" "$state")" + label=$(fm_alias_for_id "$(window_to_task "$win" "$state")") tail40=$(fm_backend_capture "$backend" "$win" 40 "$label" 2>/dev/null) || return 2 bs=$(fm_backend_busy_state "$backend" "$win" 2>/dev/null) case "$bs" in diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 83b66cc2a..a5e5d6a6d 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -885,9 +885,9 @@ cleanup_firstmate_home_children() { if [ "$child_backend" = zellij ]; then # Zellij titles are scoped by the owning home tag, so forced secondmate # cleanup must verify child tabs as that child home, not the parent. - ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" ) 2>/dev/null || true + ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "$(fm_alias_for_id "$child_id")" ) 2>/dev/null || true else - fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" 2>/dev/null || true + fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "$(fm_alias_for_id "$child_id")" 2>/dev/null || true fi fi if [ "$child_kind" = secondmate ]; then @@ -1005,7 +1005,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" "$WT/.fm-grok-turnend" fi - [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true + [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "$(fm_alias_for_id "$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) @@ -1031,7 +1031,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then fi if [ "$BACKEND" != orca ]; then - fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true + fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "$(fm_alias_for_id "$ID")" 2>/dev/null || true fi if [ "$KIND" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT diff --git a/docs/orca-backend.md b/docs/orca-backend.md index f9ff8b323..2db96b90c 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -60,6 +60,7 @@ worktree= ``` `window=` remains the shared firstmate alias used by selector-driven supervision tools after a task selector has resolved through metadata. +The alias is idempotent: a task id already beginning with `fm-` (e.g. `fm-orca-fix-k2`) keeps that exact name rather than becoming `fm-fm-orca-fix-k2`. `fm-teardown.sh ` uses the same recorded fields after loading `state/.meta`. For Orca, `window=` keeps the stable firstmate alias while `terminal=` carries the stable Orca terminal handle that backend operations use. The recorded `backend=orca` field tells shared call sites to route capture, send, interrupt, and close through `bin/backends/orca.sh` instead of tmux assumptions. @@ -69,7 +70,7 @@ The recorded `backend=orca` field tells shared call sites to route capture, send Spawn: 1. Ensure the project repo is registered in Orca, adding it with `orca repo add --path` when needed. -2. Create an independent Orca worktree with `orca worktree create --repo id: --name fm- --no-parent --setup skip`. +2. Create an Orca worktree with `orca worktree create --repo id: --name fm- --parent-worktree id:$ORCA_WORKTREE_ID --setup skip` when firstmate itself runs under Orca (so the delegate nests beneath the captain's session in the sidebar), or `--no-parent` when no trustworthy parent is discoverable. 3. Reuse the terminal returned by Orca worktree creation only when it appears in the verified `result.terminal.handle` shape, or create a titled terminal in that worktree when Orca returns only the worktree. 4. Install firstmate's per-harness turn-end hooks in the Orca worktree. 5. Write metadata, then send `GOTMPDIR` export and the selected harness launch through the recorded Orca terminal. @@ -108,6 +109,7 @@ Firstmate intentionally ignores speculative terminal-handle shapes such as bare Fake-Orca tests cover: - helper parsing for repo registration, worktree creation, verified implicit-terminal reuse, terminal creation, terminal sends, and worktree removal; +- parent linkage: `--parent-worktree` is used when firstmate runs under Orca (`ORCA_WORKTREE_ID` set and repo-qualified), with best-effort fallback to `--no-parent` when the parent is absent, stale, or not discoverable; - rejection of undocumented terminal-handle result shapes; - runtime readiness gating through `orca status --json`; - `fm-spawn.sh --backend orca` metadata creation and harness launch; diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 38fd4a9c5..472d3425d 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -396,7 +396,7 @@ test_worktree_and_terminal_helpers_parse_json() { printf '{"ok":true,"result":{"worktree":{"id":"wt-123","path":"/tmp/orca-wt"}}}\n' > "$RESP/3.out" printf '{"ok":true,"result":{"terminal":{"handle":"term-123"}}}\n' > "$RESP/4.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" ) + bash -c 'unset ORCA_WORKTREE_ID; . "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" ) wt_id=${out%%$'\t'*} wt_path=${out#*$'\t'} [ "$wt_id" = wt-123 ] || fail "worktree helper should print worktree id, got '$wt_id'" @@ -415,6 +415,103 @@ test_worktree_and_terminal_helpers_parse_json() { pass "Orca lifecycle helpers: register repo, create worktree, create terminal, parse stable ids" } +test_parent_selector_returns_id_prefixed_worktree() { + local out + orca_case parent-selector + out=$( bash -c '. "$0/bin/backends/orca.sh"; ORCA_WORKTREE_ID="abc-123::/home/captain/firstmate" fm_backend_orca_parent_selector' "$ROOT" ) + [ "$out" = "id:abc-123::/home/captain/firstmate" ] \ + || fail "parent_selector should prepend id: to a repo-qualified ORCA_WORKTREE_ID, got '$out'" + pass "fm_backend_orca_parent_selector: returns id:-prefixed selector for a repo-qualified worktree id" +} + +test_parent_selector_refuses_empty_or_unqualified_id() { + local out status + orca_case parent-selector-reject + out=$( bash -c 'unset ORCA_WORKTREE_ID; . "$0/bin/backends/orca.sh"; fm_backend_orca_parent_selector' "$ROOT" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "parent_selector should fail when ORCA_WORKTREE_ID is unset" + out=$( bash -c 'ORCA_WORKTREE_ID=""; . "$0/bin/backends/orca.sh"; fm_backend_orca_parent_selector' "$ROOT" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "parent_selector should fail when ORCA_WORKTREE_ID is empty" + out=$( bash -c 'ORCA_WORKTREE_ID="bare-id-no-separator"; . "$0/bin/backends/orca.sh"; fm_backend_orca_parent_selector' "$ROOT" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "parent_selector should fail when ORCA_WORKTREE_ID lacks the repo-qualified :: separator" + pass "fm_backend_orca_parent_selector: refuses empty or non-repo-qualified ids, falls back to --no-parent" +} + +test_worktree_create_uses_parent_when_orca_worktree_id_set() { + local out log_text + orca_case parent-present + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-parent-present"}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-child","path":"/tmp/orca-child"}}}\n' > "$RESP/3.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + ORCA_WORKTREE_ID="captain-uuid-1::/home/captain/firstmate" \ + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" ) + log_text=$(cat "$LOG") + assert_contains "$log_text" $'orca\x1f''worktree'$'\x1f''create'$'\x1f''--repo'$'\x1f''id:repo-parent-present'$'\x1f''--name'$'\x1f''fm-task'$'\x1f''--parent-worktree'$'\x1f''id:captain-uuid-1::/home/captain/firstmate'$'\x1f''--setup'$'\x1f''skip'$'\x1f''--json' \ + "worktree create should pass --parent-worktree with id:-prefixed ORCA_WORKTREE_ID when firstmate runs under Orca" + assert_not_contains "$log_text" '--no-parent' \ + "worktree create should NOT use --no-parent when a trustworthy parent is present" + pass "fm_backend_orca_worktree_create: links delegate to captain's session as parent when ORCA_WORKTREE_ID is set" +} + +test_worktree_create_uses_no_parent_when_orca_worktree_id_absent() { + local out log_text + orca_case parent-absent + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-parent-absent"}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-no-parent","path":"/tmp/orca-no-parent"}}}\n' > "$RESP/3.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + bash -c 'unset ORCA_WORKTREE_ID; . "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" ) + log_text=$(cat "$LOG") + assert_contains "$log_text" $'orca\x1f''worktree'$'\x1f''create'$'\x1f''--repo'$'\x1f''id:repo-parent-absent'$'\x1f''--name'$'\x1f''fm-task'$'\x1f''--no-parent'$'\x1f''--setup'$'\x1f''skip'$'\x1f''--json' \ + "worktree create should use --no-parent when ORCA_WORKTREE_ID is not set" + assert_not_contains "$log_text" '--parent-worktree' \ + "worktree create should NOT pass --parent-worktree when no parent is discoverable" + pass "fm_backend_orca_worktree_create: falls back to --no-parent when firstmate is not under Orca" +} + +test_worktree_create_falls_back_to_no_parent_when_parent_link_fails() { + local out log_text create_count + orca_case parent-fallback + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-fallback"}}}\n' > "$RESP/2.out" + # Response 3: --parent-worktree attempt fails (parent stale/deleted). + printf '1\n' > "$RESP/3.exit" + # Response 4: --no-parent retry succeeds. + printf '{"ok":true,"result":{"worktree":{"id":"wt-fallback","path":"/tmp/orca-fallback"}}}\n' > "$RESP/4.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + ORCA_WORKTREE_ID="stale-uuid::/home/captain/firstmate" \ + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" ) + log_text=$(cat "$LOG") + assert_contains "$log_text" '--parent-worktree' \ + "worktree create should first try --parent-worktree when ORCA_WORKTREE_ID is set" + assert_contains "$log_text" '--no-parent' \ + "worktree create should fall back to --no-parent when parent linkage fails" + create_count=$(printf '%s\n' "$log_text" | grep -c $'orca\x1fworktree\x1fcreate') + [ "$create_count" -eq 2 ] || fail "parent fallback should produce exactly 2 worktree create calls, got $create_count" + pass "fm_backend_orca_worktree_create: best-effort parent fallback retries --no-parent when parent linkage fails" +} + +test_worktree_create_shell_safely_passes_repo_qualified_parent_id() { + local out log_text + orca_case parent-shell-safe + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-shell"}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-shell","path":"/tmp/orca-shell"}}}\n' > "$RESP/3.out" + # A repo-qualified worktree id with a path containing characters that would + # break unquoted shell expansion. The value must arrive as a single arg to + # --parent-worktree, verified by the unit-separator-delimited fakebin log. + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + ORCA_WORKTREE_ID="a1b2-c3d4::/home/captain/my firstmate home" \ + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" ) + log_text=$(cat "$LOG") + assert_contains "$log_text" $'\x1f''--parent-worktree'$'\x1f''id:a1b2-c3d4::/home/captain/my firstmate home'$'\x1f' \ + "worktree create should pass the full repo-qualified parent id as a single shell-safe argument to --parent-worktree" + pass "fm_backend_orca_worktree_create: shell-safe handling of repo-qualified worktree IDs with spaces" +} + test_worktree_create_removes_worktree_when_path_missing() { local out status orca_case lifecycle-missing-path @@ -508,6 +605,82 @@ test_spawn_writes_orca_metadata_and_launches_harness() { pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" } +test_worktree_create_honors_explicit_parent_arg_over_cleared_env() { + local out log_text + orca_case parent-arg-over-cleared-env + printf '{"ok":true,"result":{"repo":{"id":"repo-explicit"}}}\n' > "$RESP/1.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-explicit","path":"/tmp/orca-explicit-wt"}}}\n' > "$RESP/2.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + bash -c 'unset ORCA_WORKTREE_ID; . "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task "id:captain-explicit::/home/captain/firstmate"' "$ROOT" ) + log_text=$(cat "$LOG") + assert_contains "$log_text" $'\x1f''--parent-worktree'$'\x1f''id:captain-explicit::/home/captain/firstmate'$'\x1f' \ + "worktree create should use the explicit parent-selector arg even with ORCA_WORKTREE_ID cleared" + assert_not_contains "$log_text" '--no-parent' \ + "worktree create should not fall back to --no-parent when an explicit parent is threaded in" + pass "fm_backend_orca_worktree_create: honors an explicit parent-selector arg over a cleared env" +} + +test_fm_spawn_threads_inherited_captain_parent_into_orca_worktree_create() { + local proj wt data state config id out log + id="orcaspawnparent1" + proj="$TMP_ROOT/spawn-parent-project" + wt="$TMP_ROOT/spawn-parent-wt" + data="$TMP_ROOT/spawn-parent-data" + state="$TMP_ROOT/spawn-parent-state" + config="$TMP_ROOT/spawn-parent-config" + fm_git_worktree "$proj" "$wt" "fm/$id" + mkdir -p "$data/$id" "$state" "$config" + printf 'brief\n' > "$data/$id/brief.md" + touch "$state/.last-watcher-beat" + orca_case spawn-parent + log="$LOG" + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-spawn-parent"}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn-parent","path":"%s"},"terminal":{"handle":"term-spawn-parent"}}}\n' "$wt" > "$RESP/3.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + ORCA_WORKTREE_ID="captain-uuid-9::/home/captain/firstmate" \ + 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 --backend orca 2>&1 ) + expect_code 0 $? "fm-spawn.sh --backend orca should succeed under a captain Orca session"$'\n'"$out" + assert_grep $'orca\x1fworktree\x1fcreate' "$log" "spawn never reached the orca worktree create call" + assert_grep $'--parent-worktree\x1fid:captain-uuid-9::/home/captain/firstmate' "$log" \ + "spawn should thread the inherited captain id into --parent-worktree even though fm-spawn clears ORCA_WORKTREE_ID before the create call" + assert_no_grep '--no-parent' "$log" "spawn should not fall back to --no-parent when a captain session is inherited" + rm -rf "/tmp/fm-$id" + pass "fm-spawn.sh: captures the captain ORCA_WORKTREE_ID before clearing it and threads it into orca worktree create" +} + +test_fm_spawn_uses_no_parent_when_not_running_under_orca() { + local proj wt data state config id out log + id="orcaspawnnoparent1" + proj="$TMP_ROOT/spawn-no-parent-project" + wt="$TMP_ROOT/spawn-no-parent-wt" + data="$TMP_ROOT/spawn-no-parent-data" + state="$TMP_ROOT/spawn-no-parent-state" + config="$TMP_ROOT/spawn-no-parent-config" + fm_git_worktree "$proj" "$wt" "fm/$id" + mkdir -p "$data/$id" "$state" "$config" + printf 'brief\n' > "$data/$id/brief.md" + touch "$state/.last-watcher-beat" + orca_case spawn-no-parent + log="$LOG" + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-spawn-no-parent"}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn-no-parent","path":"%s"},"terminal":{"handle":"term-spawn-no-parent"}}}\n' "$wt" > "$RESP/3.out" + out=$( env -u ORCA_WORKTREE_ID \ + 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 --backend orca 2>&1 ) + expect_code 0 $? "fm-spawn.sh --backend orca should succeed with no captain session"$'\n'"$out" + assert_grep $'orca\x1fworktree\x1fcreate' "$log" "spawn never reached the orca worktree create call" + assert_grep '--no-parent' "$log" "spawn should use --no-parent when not running under a captain Orca session" + assert_no_grep '--parent-worktree' "$log" "spawn should not pass --parent-worktree when no captain session is inherited" + rm -rf "/tmp/fm-$id" + pass "fm-spawn.sh: falls back to --no-parent when no trustworthy captain ORCA_WORKTREE_ID is inherited" +} + test_spawn_refuses_orca_secondmate_before_home_mutation() { local home subhome data state config id out status id="orcasmz1" @@ -1297,6 +1470,15 @@ test_dispatcher_sources_orca_and_routes_primitives test_json_get_ignores_undocumented_terminal_id_shapes test_worktree_and_terminal_helpers_parse_json test_worktree_create_removes_worktree_when_path_missing +test_parent_selector_returns_id_prefixed_worktree +test_parent_selector_refuses_empty_or_unqualified_id +test_worktree_create_uses_parent_when_orca_worktree_id_set +test_worktree_create_uses_no_parent_when_orca_worktree_id_absent +test_worktree_create_falls_back_to_no_parent_when_parent_link_fails +test_worktree_create_shell_safely_passes_repo_qualified_parent_id +test_worktree_create_honors_explicit_parent_arg_over_cleared_env +test_fm_spawn_threads_inherited_captain_parent_into_orca_worktree_create +test_fm_spawn_uses_no_parent_when_not_running_under_orca test_spawn_preserves_orca_metadata_when_pathless_worktree_cleanup_fails test_spawn_writes_orca_metadata_and_launches_harness test_spawn_refuses_orca_secondmate_before_home_mutation diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index cd815f547..3530cab5d 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -110,7 +110,7 @@ BASE_REF=$(resolve_base_ref) \ # tmux-only conformance run the tmux adapter's behavior is what is under test, # and that is unchanged by any later (e.g. non-tmux backend) addition to # fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-tasks-axi-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-backend.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-supervision-lib.sh fm-lock-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-marker-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-tasks-axi-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-backend.sh fm-decision-hold.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh" build_old_bin() { # -> echoes root dir (root/bin/