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
12 changes: 8 additions & 4 deletions bin/backends/orca.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,20 @@ fm_backend_orca_run_json() {
}

fm_backend_orca_repo_ensure() { # <project-path>
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"
Expand Down
7 changes: 5 additions & 2 deletions docs/orca-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> && 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=<handle>` in the task's meta, with `window=fm-<id>` as the shared firstmate alias).
You do not need to open the app for routine supervision: `bin/fm-peek.sh fm-<id>` reads a task's terminal without opening Orca, and `bin/fm-send.sh fm-<id> "<text>"` steers it (Enter and Ctrl-C are supported; Escape is not).
Expand Down Expand Up @@ -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 <dir> && 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:<repo> --name fm-<id> --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.
Expand Down Expand Up @@ -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:

Expand Down
30 changes: 15 additions & 15 deletions tests/fm-afk-inject-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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" \
Expand Down Expand Up @@ -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"
}

Expand All @@ -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
Expand All @@ -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).
Expand Down
53 changes: 41 additions & 12 deletions tests/fm-backend-orca.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ set -u
TMP_ROOT=$(fm_test_tmproot fm-backend-orca-tests)

make_orca_fakebin() { # <dir> -> echoes fakebin dir
local dir=$1 fb="$1/fakebin"
local fb="$1/fakebin"
mkdir -p "$fb"
cat > "$fb/orca" <<'SH'
#!/usr/bin/env bash
Expand Down Expand Up @@ -49,7 +49,7 @@ orca_case() { # <name> -> sets CASE_DIR LOG RESP FB
}

neutral_fm_root() { # <dir> -> 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
Expand Down Expand Up @@ -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' | \
Expand All @@ -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'}
Expand All @@ -375,25 +377,28 @@ 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'"
[ "$wt_path" = /tmp/orca-wt ] || fail "worktree helper should print worktree path, got '$wt_path'"
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' \
Expand All @@ -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" \
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-backend-tmux-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ cleanup_all() {
SHIM_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-backend-smoke.XXXXXX")
cat > "$SHIM_DIR/tmux" <<SH
#!/usr/bin/env bash
exec "$REAL_TMUX" -L "$SOCKET" "\$@"
exec "$REAL_TMUX" -f /dev/null -L "$SOCKET" "\$@"
SH
chmod +x "$SHIM_DIR/tmux"
PATH="$SHIM_DIR:$PATH"
Expand Down
Loading