diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index 1a4311a2..6d3a3363 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -107,16 +107,20 @@ fm_backend_orca_run_json() { } fm_backend_orca_repo_ensure() { # - local project=$1 out repo_id + local project=$1 out repo_id phys fm_backend_orca_tool_check || return 1 - out=$(orca repo show --repo "path:$project" --json 2>/dev/null || true) + phys=$(cd "$project" && pwd -P) || { + echo "error: cannot resolve physical path for $project" >&2 + return 1 + } + out=$(orca repo show --repo "path:$phys" --json 2>/dev/null || true) if repo_id=$(printf '%s' "$out" | fm_backend_orca_json_get repo-id 2>/dev/null); then printf '%s' "$repo_id" return 0 fi - out=$(orca repo add --path "$project" --json) || return 1 + out=$(orca repo add --path "$phys" --json) || return 1 repo_id=$(printf '%s' "$out" | fm_backend_orca_json_get repo-id) || { - echo "error: orca repo add did not return a repo id for $project" >&2 + echo "error: orca repo add did not return a repo id for $phys" >&2 return 1 } printf '%s' "$repo_id" diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 55142998..f0fe97d3 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -22,6 +22,7 @@ When bootstrap resolves Orca from `FM_BACKEND=orca` or `config/backend=orca`, it First run: before spawn mutates any repo or worktree state, firstmate runs `orca status --json` and requires the app to report `reachable=true` and `state="ready"` - start the Orca app and wait for it to finish loading before spawning. Spawn fails closed if the runtime is not ready. The first spawn against a given project also auto-registers that project's repo in Orca (`orca repo add --path`) if it is not already registered - no manual registration step is needed. +The project directory is resolved to its physical path (`cd && pwd -P`) before any Orca repo lookup or registration call, so a secondmate home that points at the primary's project clone via a symlink reuses the canonical project's existing Orca registration instead of creating a duplicate entry. Watching and attaching: Orca owns both the worktree and the terminal for its tasks, so there is nothing to attach to outside the Orca app itself - open the app and find the terminal for the task (recorded as `terminal=` in the task's meta, with `window=fm-` as the shared firstmate alias). You do not need to open the app for routine supervision: `bin/fm-peek.sh fm-` reads a task's terminal without opening Orca, and `bin/fm-send.sh fm- ""` steers it (Enter and Ctrl-C are supported; Escape is not). @@ -67,7 +68,8 @@ 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. +1. Resolve the project directory to its physical path via `cd && pwd -P`, then ensure that physical path is registered as a repo in Orca, adding it with `orca repo add --path` when needed. + This physical-path resolution prevents secondmate homes - which symlink to the primary's project clone - from registering duplicate Orca repos under the symlink path. 2. Create an independent Orca worktree with `orca worktree create --repo id: --name fm- --no-parent --setup skip`. 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. @@ -112,7 +114,8 @@ Fake-Orca tests cover: - `fm-peek.sh`, `fm-send.sh`, and `fm-crew-state.sh` routing through recorded Orca metadata; - slash-command popup placeholder handling that requires a second Enter before `fm-send.sh` reports submission; - scout teardown releasing an Orca worktree through `orca worktree rm`; -- ship teardown failing closed when the recorded Orca worktree id is missing, cannot resolve to a path, or resolves to a different path than `worktree=`. +- ship teardown failing closed when the recorded Orca worktree id is missing, cannot resolve to a path, or resolves to a different path than `worktree=`; +- `fm_backend_orca_repo_ensure` resolving a symlinked project directory to its physical path before passing it to Orca, confirming a symlink and its canonical target register under the same physical path. Run the focused suite with: diff --git a/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index 415c7db4..fb5cea9c 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -54,7 +54,7 @@ cleanup_all() { wait "$DAEMON_PID" 2>/dev/null || true fi if [ -n "${SOCKET:-}" ] && [ -n "${REAL_TMUX:-}" ]; then - "$REAL_TMUX" -L "$SOCKET" kill-server 2>/dev/null || true + "$REAL_TMUX" -f /dev/null -L "$SOCKET" kill-server 2>/dev/null || true fi rm -rf "${TMUX_SHIM_DIR:-}" 2>/dev/null || true rm -rf "${STATE_DIR:-}" 2>/dev/null || true @@ -73,8 +73,8 @@ LOG_FILE="$STATE_DIR/submitted.log" . "$DAEMON" # Private tmux server with a supervisor session. -"$REAL_TMUX" -L "$SOCKET" new-session -d -s supervisor -x 200 -y 50 -SUPERVISOR_PANE=$("$REAL_TMUX" -L "$SOCKET" display-message -p -t supervisor '#{pane_id}') +"$REAL_TMUX" -f /dev/null -L "$SOCKET" new-session -d -s supervisor -x 200 -y 50 +SUPERVISOR_PANE=$("$REAL_TMUX" -f /dev/null -L "$SOCKET" display-message -p -t supervisor '#{pane_id}') # Supervisor pane loop: a small deterministic composer that logs each submitted # line verbatim (hex + text + classification). It draws the in-progress input @@ -126,7 +126,7 @@ LOOP chmod +x "$LOOP_SCRIPT" # Start the loop in the supervisor pane. -"$REAL_TMUX" -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" \ +"$REAL_TMUX" -f /dev/null -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" \ "bash '$LOOP_SCRIPT' '$LOG_FILE'" Enter sleep 1 # let the loop start and settle @@ -145,15 +145,15 @@ if [ "\${1:-}" = "send-keys" ] && [ -f "$STATE_DIR/.swallow-enter" ]; then fi _args+=("\$_arg") done - exec "$REAL_TMUX" -L "$SOCKET" send-keys "\${_args[@]}" + exec "$REAL_TMUX" -f /dev/null -L "$SOCKET" send-keys "\${_args[@]}" fi -exec "$REAL_TMUX" -L "$SOCKET" "\$@" +exec "$REAL_TMUX" -f /dev/null -L "$SOCKET" "\$@" SHIM chmod +x "$TMUX_SHIM_DIR/tmux" # Create a fake crewmate window (the watcher lists fm-* windows for stale # detection). The pane is an inert shell — it just needs to exist. -"$REAL_TMUX" -L "$SOCKET" new-window -d -n fm-fake-c1 -t supervisor +"$REAL_TMUX" -f /dev/null -L "$SOCKET" new-window -d -n fm-fake-c1 -t supervisor start_daemon() { PATH="$TMUX_SHIM_DIR:$PATH" \ @@ -217,24 +217,24 @@ reset_state() { selfcheck_pane_input_pending() { local check_text="selfcheck-marker-12345" - "$REAL_TMUX" -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" -l "$check_text" + "$REAL_TMUX" -f /dev/null -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" -l "$check_text" sleep 0.5 if PATH="$TMUX_SHIM_DIR:$PATH" pane_input_pending "$SUPERVISOR_PANE"; then # Detected — clean up the text and proceed. - "$REAL_TMUX" -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" Enter + "$REAL_TMUX" -f /dev/null -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" Enter sleep 0.3 return 0 fi # Not detected - print diagnostics and fail. echo "pane_input_pending cannot detect typed text in this tmux environment" >&2 local _cy _line - _cy=$("$REAL_TMUX" -L "$SOCKET" display-message -p -t "$SUPERVISOR_PANE" '#{cursor_y}' 2>/dev/null) + _cy=$("$REAL_TMUX" -f /dev/null -L "$SOCKET" display-message -p -t "$SUPERVISOR_PANE" '#{cursor_y}' 2>/dev/null) echo " cursor_y=$_cy" >&2 echo " pane capture (first 10 lines):" >&2 - "$REAL_TMUX" -L "$SOCKET" capture-pane -p -t "$SUPERVISOR_PANE" 2>/dev/null | head -10 | sed 's/^/ /' >&2 - _line=$("$REAL_TMUX" -L "$SOCKET" capture-pane -p -t "$SUPERVISOR_PANE" 2>/dev/null | sed -n "$((_cy + 1))p") + "$REAL_TMUX" -f /dev/null -L "$SOCKET" capture-pane -p -t "$SUPERVISOR_PANE" 2>/dev/null | head -10 | sed 's/^/ /' >&2 + _line=$("$REAL_TMUX" -f /dev/null -L "$SOCKET" capture-pane -p -t "$SUPERVISOR_PANE" 2>/dev/null | sed -n "$((_cy + 1))p") echo " cursor line: '$_line'" >&2 - "$REAL_TMUX" -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" Enter + "$REAL_TMUX" -f /dev/null -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" Enter fail "pane_input_pending self-check failed" } @@ -249,7 +249,7 @@ test_scenario_a() { # Type partial text into the supervisor pane with NO Enter. This simulates the # captain returning and starting to type before afk has been cleared. - "$REAL_TMUX" -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" -l "human draft text" + "$REAL_TMUX" -f /dev/null -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" -l "human draft text" sleep 0.5 # Write a captain-relevant status to trigger a real escalation through the @@ -271,7 +271,7 @@ test_scenario_a() { fi # Now submit the human's text (Enter). The pane goes idle. - "$REAL_TMUX" -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" Enter + "$REAL_TMUX" -f /dev/null -L "$SOCKET" send-keys -t "$SUPERVISOR_PANE" Enter sleep 0.5 # Wait for the daemon to retry injection (housekeeping tick = 1s). diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 6cf67f17..e796ed76 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -9,7 +9,7 @@ set -u TMP_ROOT=$(fm_test_tmproot fm-backend-orca-tests) make_orca_fakebin() { # -> echoes fakebin dir - local dir=$1 fb="$1/fakebin" + local fb="$1/fakebin" mkdir -p "$fb" cat > "$fb/orca" <<'SH' #!/usr/bin/env bash @@ -49,7 +49,7 @@ orca_case() { # -> sets CASE_DIR LOG RESP FB } neutral_fm_root() { # -> echoes a minimal root with a quiet guard - local dir=$1 root="$1/root" + local root="$1/root" mkdir -p "$root/bin" cat > "$root/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -349,8 +349,10 @@ test_worktree_path_resolves_id() { } test_json_get_ignores_undocumented_terminal_id_shapes() { - local out status wt_id wt_path term + local out status wt_id wt_path term proj_dir orca_case parser-pruned-terminal-shapes + proj_dir="$CASE_DIR/project" + mkdir -p "$proj_dir" set +e out=$( printf '{"ok":true,"result":{"id":"term-root-id"}}\n' | \ @@ -363,7 +365,7 @@ test_json_get_ignores_undocumented_terminal_id_shapes() { printf '{"ok":true,"result":{"repo":{"id":"repo-123"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-123","path":"/tmp/orca-wt","terminal":{"handle":"term-nested"}}}}\n' > "$RESP/3.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 '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create "$1" fm-task' "$ROOT" "$proj_dir" ) wt_id=${out%%$'\t'*} wt_path=${out#*$'\t'} term=${wt_path#*$'\t'} @@ -375,14 +377,17 @@ test_json_get_ignores_undocumented_terminal_id_shapes() { } test_worktree_and_terminal_helpers_parse_json() { - local out wt_id wt_path term + local out wt_id wt_path term proj_dir proj_phys orca_case lifecycle-helpers + proj_dir="$CASE_DIR/project" + mkdir -p "$proj_dir" + proj_phys=$(cd "$proj_dir" && pwd -P) printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-123"}}}\n' > "$RESP/2.out" 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 '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create "$1" fm-task' "$ROOT" "$proj_dir" ) wt_id=${out%%$'\t'*} wt_path=${out#*$'\t'} [ "$wt_id" = wt-123 ] || fail "worktree helper should print worktree id, got '$wt_id'" @@ -390,10 +395,10 @@ test_worktree_and_terminal_helpers_parse_json() { term=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_terminal_create wt-123 fm-task' "$ROOT" ) [ "$term" = term-123 ] || fail "terminal helper should print terminal handle, got '$term'" - assert_contains "$(cat "$LOG")" $'orca\x1f''repo'$'\x1f''show'$'\x1f''--repo'$'\x1f''path:/repo/path'$'\x1f''--json' \ - "worktree helper should first check repo registration" - assert_contains "$(cat "$LOG")" $'orca\x1f''repo'$'\x1f''add'$'\x1f''--path'$'\x1f''/repo/path'$'\x1f''--json' \ - "worktree helper should register an absent repo" + assert_contains "$(cat "$LOG")" "path:$proj_phys" \ + "worktree helper should pass the physical project path to orca repo show" + assert_contains "$(cat "$LOG")" $'orca\x1f''repo'$'\x1f''add'$'\x1f''--path'$'\x1f'"$proj_phys"$'\x1f''--json' \ + "worktree helper should register an absent repo using the physical path" assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''create'$'\x1f''--repo'$'\x1f''id:repo-123'$'\x1f''--name'$'\x1f''fm-task'$'\x1f''--no-parent'$'\x1f''--setup'$'\x1f''skip'$'\x1f''--json' \ "worktree helper did not create an independent no-hook worktree" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''create'$'\x1f''--worktree'$'\x1f''id:wt-123'$'\x1f''--title'$'\x1f''fm-task'$'\x1f''--json' \ @@ -402,13 +407,15 @@ test_worktree_and_terminal_helpers_parse_json() { } test_worktree_create_removes_worktree_when_path_missing() { - local out status + local out status proj_dir orca_case lifecycle-missing-path + proj_dir="$CASE_DIR/project" + mkdir -p "$proj_dir" printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-no-path"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-no-path"},"terminal":{"handle":"term-no-path"}}}\n' > "$RESP/3.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" 2>&1 ) + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create "$1" fm-task' "$ROOT" "$proj_dir" 2>&1 ) status=$? [ "$status" -ne 0 ] || fail "worktree helper should fail when Orca omits the worktree path" assert_contains "$out" "orca worktree create did not return a path for fm-task" \ @@ -1256,6 +1263,27 @@ test_dispatcher_sources_orca_and_routes_primitives() { pass "fm-backend dispatcher: accepts orca and routes capture through bin/backends/orca.sh" } +test_repo_ensure_resolves_symlinked_project_to_physical_path() { + local canonical phys_canonical symlink out log_text + orca_case repo-ensure-realpath + canonical="$CASE_DIR/canonical" + mkdir "$canonical" + phys_canonical=$(cd "$canonical" && pwd -P) + symlink="$CASE_DIR/symlink-project" + ln -s "$canonical" "$symlink" + printf '1\n' > "$RESP/1.exit" + printf '{"ok":true,"result":{"repo":{"id":"repo-canonical"}}}\n' > "$RESP/2.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_repo_ensure "$1"' "$ROOT" "$symlink" ) + [ "$out" = "repo-canonical" ] || fail "repo_ensure should return the repo id, got '$out'" + log_text=$(cat "$LOG") + assert_not_contains "$log_text" "path:$symlink" \ + "repo_ensure must not pass the symlink path to orca" + assert_contains "$log_text" "path:$phys_canonical" \ + "repo_ensure must pass the physical canonical path to orca" + pass "fm_backend_orca_repo_ensure: resolves symlinked project dir to its physical path before Orca calls" +} + test_capture_reads_terminal_tail_json test_capture_falls_back_to_text_fields test_capture_fails_on_orca_error_json @@ -1277,6 +1305,7 @@ test_remove_worktree_refuses_empty_id test_remove_worktree_rejects_orca_error_json test_worktree_path_resolves_id test_dispatcher_sources_orca_and_routes_primitives +test_repo_ensure_resolves_symlinked_project_to_physical_path test_json_get_ignores_undocumented_terminal_id_shapes test_worktree_and_terminal_helpers_parse_json test_worktree_create_removes_worktree_when_path_missing diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index 0d47b83e..78680131 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -30,7 +30,7 @@ cleanup_all() { SHIM_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-backend-smoke.XXXXXX") cat > "$SHIM_DIR/tmux" <