Merge remote-tracking branch 'origin/main' into brennanb2025/workspace-cleanup-dialog

# Conflicts:
#	src/main/ipc/workspace-cleanup-disconnected-ssh.ts
#	src/main/ipc/workspace-cleanup-scan.ts
#	src/preload/api-types.ts
This commit is contained in:
Brennan Benson
2026-08-13 22:22:14 -07:00
947 changed files with 36012 additions and 14587 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ If your change affects UI or interaction behavior, verify it on the platforms it
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them.
CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
CI enforces this for `src/preload/` and `src/shared/`.
## Pull Requests
+5 -1
View File
@@ -240,7 +240,11 @@ jobs:
echo "Changed startup-readiness specs are owned by the dedicated live lane."
exit 0
fi
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 \
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
fi
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
- name: Upload Playwright traces
+23 -4
View File
@@ -23,7 +23,7 @@ jobs:
platform: linux
- os: macos-15
platform: mac
# Match the release gate: Windows runs the fresh-startup golden only.
# Match the release gate: Windows runs a focused golden subset.
- os: windows-2022
platform: windows
@@ -61,19 +61,33 @@ jobs:
- name: Build Electron app for E2E
run: npx electron-vite build --mode e2e
# Why: this workflow can check out an older ref than the YAML that
# invoked it. Skip goldens the checked-out tree does not define.
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run golden E2E tests on Windows
if: runner.os == 'Windows'
@@ -81,7 +95,12 @@ jobs:
run: |
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run test:e2e:windows-fresh-startup-golden
pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
}
pnpm run --if-present test:e2e:source-control-golden
- name: Upload Playwright traces
if: failure()
+8 -3
View File
@@ -74,14 +74,14 @@ jobs:
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
# past typecheck. See docs/preload-typecheck-hole.md.
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
- name: Guard against project-owned .d.ts in preload/shared
run: |
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
if [ -n "$matches" ]; then
echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared."
echo "Move type declarations into a .ts file so skipLibCheck does not hide errors."
echo "See docs/preload-typecheck-hole.md."
echo "See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts."
echo "Found:"
echo "$matches"
exit 1
@@ -383,7 +383,10 @@ jobs:
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never
- name: Verify headless serve signal shutdown
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
@@ -431,6 +434,8 @@ jobs:
pnpm exec vitest run --config config/vitest.config.ts
src/main/cli/wsl-cli-powershell-boundary.test.ts
src/main/orca-profiles/profile-index-store.test.ts
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
src/shared/secure-file-fsync-flags.test.ts
- name: Build package inputs
+29 -4
View File
@@ -783,7 +783,8 @@ jobs:
{
echo "## Release E2E Signal"
echo ""
echo "- Platform golden E2E is release-blocking: terminal rendering and restrictive-umask profile writes on Linux/macOS, plus fresh startup on Windows."
echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, source control, and agent TUI launch on Linux/macOS, plus fresh startup and source control on Windows."
echo "- Exception: every golden except terminal rendering runs with \`--if-present\`, so it is skipped (not failed) on older tags that predate its script."
echo "- Full E2E runs separately after publication and cannot change the release result."
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
echo ""
@@ -869,17 +870,39 @@ jobs:
- name: Build Electron app for platform golden
run: npx electron-vite build --mode e2e
# Why: this job is defined on the dispatch ref (usually main) but checks
# out the release tag. Cherry-pick / hotfix tags can predate a golden
# script that main already calls; --if-present keeps those cuts green
# instead of failing with ERR_PNPM_NO_SCRIPT.
- name: Run terminal rendering golden on Linux
if: runner.os == 'Linux'
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
- name: Run source-control golden on Linux
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run terminal rendering golden on macOS
if: runner.os == 'macOS'
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
- name: Run agent TUI golden on Linux
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
- name: Run agent TUI golden on macOS
if: runner.os == 'macOS'
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
- name: Run source-control golden on macOS
if: runner.os == 'macOS'
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run fresh-startup golden on Windows
if: runner.os == 'Windows'
@@ -887,7 +910,9 @@ jobs:
run: |
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run test:e2e:windows-fresh-startup-golden
pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
pnpm run --if-present test:e2e:source-control-golden
- name: Upload Playwright traces
if: failure()
@@ -0,0 +1,37 @@
FROM ubuntu@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
dbus-x11 \
iproute2 \
jq \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libasound2t64 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
p7zip-full \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY run-signal-case.sh /usr/local/bin/run-signal-case
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
set -euo pipefail
signal_name=${1:?signal name is required}
app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root}
signal_target_kind=${ORCA_SIGNAL_TARGET:-app}
entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app}
int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group}
startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90}
if ((EUID == 0)); then
exec runuser --user orca --preserve-environment -- "$0" "$@"
fi
case "$signal_name" in
INT|TERM) ;;
*) echo "unsupported signal: $signal_name" >&2; exit 64 ;;
esac
state_dir=$(mktemp -d "/tmp/orca-shutdown-${signal_name}.XXXXXX")
stdout_log="$state_dir/stdout.log"
stderr_log="$state_dir/stderr.log"
ulimit -c 0
sleep 300 &
canary_pid=$!
canary_start_ticks=$(awk '{print $22}' "/proc/$canary_pid/stat")
cleanup() {
kill "$canary_pid" 2>/dev/null || true
wait "$canary_pid" 2>/dev/null || true
}
trap cleanup EXIT
export HOME="$state_dir/home"
export XDG_CONFIG_HOME="$state_dir/config"
export XDG_CACHE_HOME="$state_dir/cache"
export XDG_RUNTIME_DIR="$state_dir/runtime"
export LIBGL_ALWAYS_SOFTWARE=1
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
case "$entrypoint_kind" in
app) entrypoint=("$app_root/AppRun" --no-sandbox) ;;
launcher)
export ELECTRON_DISABLE_SANDBOX=1
entrypoint=("$app_root/resources/bin/orca-ide")
;;
*) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;;
esac
setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0.0.1 --json \
>"$stdout_log" 2>"$stderr_log" &
app_pid=$!
app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat")
# The inner shell expands its positional parameters.
# shellcheck disable=SC2016
ready_line=$(timeout "$startup_timeout_seconds" bash -c '
tail --pid="$1" -n +1 -F "$2" 2>/dev/null \
| jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\''
' bash "$app_pid" "$stdout_log" || true)
if [[ -z "$ready_line" ]]; then
cat "$stdout_log" "$stderr_log" >&2
echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2
exit 1
fi
bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line")
bound_port=${bound_endpoint##*:}
listener_before=$(ss -H -ltnp "sport = :$bound_port" || true)
if [[ -z "$listener_before" ]]; then
echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2
exit 1
fi
tree_pids=()
declare -A tree_start_ticks
tree_start_ticks["$app_pid"]=$app_start_ticks
frontier=("$app_pid")
while ((${#frontier[@]})); do
parent=${frontier[0]}
frontier=("${frontier[@]:1}")
while read -r child; do
[[ -n "$child" ]] || continue
child_start_ticks=$(awk '{print $22}' "/proc/$child/stat" 2>/dev/null || true)
[[ -n "$child_start_ticks" ]] || continue
tree_pids+=("$child")
tree_start_ticks["$child"]=$child_start_ticks
frontier+=("$child")
done < <(ps -o pid= --ppid "$parent" | tr -d ' ')
done
tree_pid_csv="$app_pid"
for pid in "${tree_pids[@]}"; do
tree_pid_csv+=",$pid"
done
tree_snapshot=$(ps -o pid=,ppid=,pgid=,lstart=,stat=,args= -p "$tree_pid_csv" 2>/dev/null || true)
xvfb_pids=$(awk '/[X]vfb :99 / {print $1}' <<<"$tree_snapshot" | paste -sd, -)
if [[ -z "$xvfb_pids" ]]; then
echo "$tree_snapshot" >&2
echo "FAIL: no run-owned Xvfb :99 process found after readiness" >&2
exit 1
fi
signal_target_pid=$app_pid
if [[ "$signal_target_kind" == serving-electron ]]; then
signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot")
[[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; }
elif [[ "$signal_target_kind" != app ]]; then
echo "unsupported signal target: $signal_target_kind" >&2
exit 64
fi
signal_target_start_ticks=${tree_start_ticks[$signal_target_pid]:-}
if [[ -z "$signal_target_start_ticks" ]] \
|| [[ $(awk '{print $22}' "/proc/$signal_target_pid/stat") != "$signal_target_start_ticks" ]]; then
echo "FAIL: signal target identity changed before delivery" >&2
exit 1
fi
signal_delivery=pid
if [[ "$signal_name" == INT && "$int_delivery" == foreground-process-group ]]; then
signal_delivery=$int_delivery
kill -s "$signal_name" -- "-$signal_target_pid"
else
kill -s "$signal_name" "$signal_target_pid"
fi
sleep 30 &
watchdog_pid=$!
set +e
wait -n -p completed_pid "$app_pid" "$watchdog_pid"
wait_status=$?
set -e
if [[ "$completed_pid" == "$watchdog_pid" ]]; then
echo "FAIL: foreground AppRun did not exit after $signal_name" >&2
exit 1
fi
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
survivors=()
for pid in "${tree_pids[@]}"; do
if [[ -r "/proc/$pid/stat" ]] \
&& [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
&& ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
survivors+=("$pid")
fi
done
owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
'($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
canary_alive=false
if kill -0 "$canary_pid" 2>/dev/null \
&& [[ $(awk '{print $22}' "/proc/$canary_pid/stat") == "$canary_start_ticks" ]]; then
canary_alive=true
fi
fatal_evidence=false
if grep -Eq 'Failed to shutdown|SIGTRAP|Trace/breakpoint trap|core dumped' \
"$stdout_log" "$stderr_log"; then
fatal_evidence=true
fi
jq -nc \
--arg signal "$signal_name" \
--arg signalDelivery "$signal_delivery" \
--arg entrypointKind "$entrypoint_kind" \
--arg signalTargetKind "$signal_target_kind" \
--argjson appPid "$app_pid" \
--argjson signalTargetPid "$signal_target_pid" \
--arg endpoint "$bound_endpoint" \
--arg listenerBefore "$listener_before" \
--arg listenerAfter "$listener_after" \
--arg xvfbPids "$xvfb_pids" \
--arg treeBefore "$tree_snapshot" \
--argjson waitStatus "$wait_status" \
--argjson fatalEvidence "$fatal_evidence" \
--argjson canaryAlive "$canary_alive" \
--arg survivors "${survivors[*]:-}" \
--arg residue "$owned_residue" \
--arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \
'{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \
|| [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then
echo "--- stdout ---" >&2
cat "$stdout_log" >&2
echo "--- stderr ---" >&2
cat "$stderr_log" >&2
exit 1
fi
+1 -3
View File
@@ -167,7 +167,6 @@ inline src/main/window/attach-main-window-services.ts
inline src/main/window/createMainWindow.test.ts
inline src/main/window/createMainWindow.ts
inline src/main/workspace-space-analysis.ts
inline src/preload/api-types.ts
inline src/preload/index.ts
inline src/relay/agent-hook-server.ts
inline src/relay/dispatcher.ts
@@ -317,7 +316,7 @@ inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/github-project-types.ts
inline src/shared/github/project-types.ts
inline src/shared/keybindings.test.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
@@ -326,7 +325,6 @@ inline src/shared/runtime-types.ts
inline src/shared/source-control-ai.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline src/shared/types.ts
inline tests/e2e/helpers/terminal.ts
inline tests/e2e/terminal-panes.spec.ts
mobile-config app/h/*/files/*.tsx
+368 -12
View File
@@ -785,6 +785,143 @@
],
"demotionRule": "Keep experimental or demote if focused tests flake, HOST_OFFLINE can park indefinitely, direct probes serialize configured endpoints, loser cleanup leaks clients, or physical-device radio cost exceeds the measured budget."
},
{
"id": "mobile-transport.lifecycle-liveness",
"title": "Wanted mobile hosts self-heal without false physical-session death",
"maturity": "experimental",
"protection": "partial",
"owner": "mobile-runtime",
"layer": "mobile-provider-and-physical-session-contract",
"surfaces": [
"mobile host acquisition",
"Keychain and Keystore catalog reads",
"direct LAN and Tailscale RPC",
"cloud relay RPC",
"foreground and network revival"
],
"platforms": ["ios", "android", "macos", "linux", "windows"],
"providers": ["lan", "tailscale", "cloud-relay"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["lan", "tailscale", "cloud-relay"],
"coverageNotes": "Deterministic React/provider tests cover catalog, missing-host, construction, cancellation, release, forget, refresh, backoff, and stale-generation outcomes. Direct sessions cover idle probing and three fair misses. Relay sessions cover zero idle polling, rate-limited foreground probes, two fair misses, authenticated activity before semantic decoding, delivery ambiguity, scheduler stalls, and write failure. Physical iOS/Android sleep, carrier/Tailscale loss, and production relay paths remain live-test gaps.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-4153/mobile-app-disconnects-frequently"
],
"invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions emit no periodic idle or demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death.",
"oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay zero idle and demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay.",
"commands": [
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts"
],
"testFiles": [
"mobile/src/transport/client-context.test.ts",
"mobile/src/transport/host-open-recovery.test.tsx",
"mobile/src/transport/host-open-retry-scheduler.test.ts",
"mobile/src/transport/rpc-session-liveness-watchdog.test.ts",
"mobile/src/transport/rpc-session-liveness-integration.test.ts",
"mobile/src/transport/mobile-relay-rpc-session.test.ts",
"mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts",
"mobile/src/transport/mobile-endpoint-nudge-router.test.ts",
"mobile/src/transport/mobile-relay-e2ee-link.test.ts",
"mobile/src/transport/stable-logical-rpc-client.test.ts"
],
"assertionRefs": [
{
"file": "mobile/src/transport/client-context.test.ts",
"assertions": ["a retired close-on-release owner closes an ownerless manual reconnect"]
},
{
"file": "mobile/src/transport/host-open-recovery.test.tsx",
"assertions": [
"catalog, missing-host, and construction failures retry without remounting",
"final release and forget prevent later client publication",
"refresh bypasses a never-settling stale open and stale failure cannot clobber its replacement",
"revival expedites recovery without resetting the failure tier",
"a retired owner's release cannot cancel a newer owner's open",
"explicit reconnect demand survives a pre-client failure"
]
},
{
"file": "mobile/src/transport/rpc-session-liveness-watchdog.test.ts",
"assertions": [
"authenticated traffic resets the idle and missed-probe clocks",
"two fair misses are tolerated and the third terminates exactly once",
"scheduler stalls and replacement identities cannot terminate the current session",
"continuous authenticated traffic does not churn idle timers per frame"
]
},
{
"file": "mobile/src/transport/rpc-session-liveness-integration.test.ts",
"assertions": [
"direct handshake and probe write exceptions recover without escaping",
"one silent foreground window preserves the connected direct socket",
"authenticated terminal binary output restores direct-session liveness",
"a replay write failure retains every queued stream for the replacement session",
"liveness probe replies cannot consume the authentication retry budget"
]
},
{
"file": "mobile/src/transport/mobile-relay-rpc-session.test.ts",
"assertions": [
"written Relay requests retain delivery-unknown failures",
"resume confirmation and stream routing remain authenticated"
]
},
{
"file": "mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts",
"assertions": [
"an idle Relay emits zero periodic traffic",
"RPCs and subscriptions after prolonged silence emit no liveness probe",
"foreground Relay liveness uses two fair 4-second misses and a 10-second sequence rate limit",
"network change does not probe the replaced Relay",
"a failed foreground probe write disconnects immediately"
]
},
{
"file": "mobile/src/transport/stable-logical-rpc-client.test.ts",
"assertions": ["a delivery-unknown mutation is not replayed onto a replacement Relay"]
}
],
"evidenceRuns": [
{
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts",
"result": "passed",
"durationSeconds": 0.74,
"summary": "Ten focused files passed 66 deterministic provider lifecycle, direct/Relay liveness, traffic, and delivery-ambiguity tests."
}
],
"runtimeBudget": {
"p95Seconds": 5,
"scope": "focused mobile provider and transport unit tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One deterministic local focused run exists; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Current main failed six deterministic assertions: terminal catalog failure, zero-owner publication, stale failure clobber, initial direct handshake write throw, one-window direct death, and foreground probe write throw. The candidate passes the same assertions and adds relay parity, backoff, cancellation, and scheduler-fairness coverage."
},
"performanceBudget": {
"required": true,
"evidence": "Each wanted clientless host owns at most one open and one retry timer with a 60-second capped tail. Direct sessions own one idle or probe timer. Idle Relay sessions own no liveness timer and emit no periodic or demand-driven liveness traffic; foreground sequences start at most once per 10 seconds. No subprocess, wire opcode, or server-side work is introduced."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history.",
"Capture the existing final-timeout log immediately before an affected-device drop.",
"Run physical iOS and Android direct LAN, Tailscale, relay, background/resume, and network-handoff journeys.",
"Measure reconnect radio and battery impact during the 60-second catalog retry tail, direct 44-second idle liveness budget, and Relay foreground probes."
],
"knownGaps": [
"No physical iOS or Android device was exercised.",
"No affected-device log yet proves the old one-window watchdog caused the reported live-session drop.",
"Carrier NAT, production Tailscale, production relay cells, and real scheduler suspension remain unmeasured.",
"Persistent pre-client failure metadata is not yet exposed in Home UI."
],
"demotionRule": "Keep experimental or demote if a wanted host can park without retry, stale work can publish or notify, disconnect or forget resurrects a host, direct closes before three fair misses, Relay polls while idle or on demand, survives two fair foreground misses, authenticated traffic fails to restore health, timers leak across identity replacement, mutations replay after ambiguous delivery, or physical-device radio cost exceeds the measured budget."
},
{
"id": "desktop-relay.assignment-backpressure",
"title": "Desktop relay drain recovery cannot amplify a director outage",
@@ -8276,10 +8413,10 @@
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.",
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, a live dev-runtime CLI repro, and a Haiku composer trace. The Claude render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/issues/7226"],
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.",
"oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.",
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude must emit a post-paste composer marker and then settle, or reach the bounded fallback first.",
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and a Claude-shaped multi-frame render where the legacy 500 ms delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
@@ -8305,6 +8442,9 @@
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"runtime writes bracketed paste before a delayed submit",
"Claude ignores unrelated and split pre-marker output, then waits for post-marker render quiescence before one submit",
"a silent Claude composer reaches one bounded fallback submit",
"a marked Claude composer with continuous render output reaches one bounded fallback submit",
"large prompt frames are chunked and reconstructed before submit",
"partial prompt write failure closes the paste frame and does not submit"
]
@@ -8334,6 +8474,24 @@
}
],
"evidenceRuns": [
{
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"result": "passed",
"durationSeconds": 13.3,
"summary": "4 files and 1,283 tests passed. The hardened multi-frame oracle failed on the first-marker candidate because it submitted at 751 ms during an intermediate Claude frame; the quiescence candidate waited through the final 1,000 ms frame and submitted once at 2,500 ms. Continuous render output remained bounded to one fallback submit at 8 seconds. An isolated Claude Code 2.1.231 Haiku probe saw the first marker at 400 ms, continued output through 1,500 ms, sent one Enter at 3,000 ms after 1.5 seconds quiet, and created the expected marker; no Fable or Opus probe was used."
},
{
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"result": "passed",
"durationSeconds": 16.9,
"summary": "4 files and 1,282 tests passed. Unmodified main wrote Enter at 500 ms before the deterministic Claude composer rendered at 750 ms; the candidate waited for the split show-cursor marker and wrote one Enter. A live Claude Code 2.1.231 Haiku trace rendered the pasted marker and show-cursor in one 523-byte frame without submitting a model request."
},
{
"date": "2026-07-07",
"runner": "local",
@@ -8363,11 +8521,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The live harness reproduced the unsafe raw multiline contract before the fix and passes after the fix; intentional-break evidence is local only and not yet in CI."
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
},
"performanceBudget": {
"required": true,
"evidence": "Agent prompt dispatch remains O(prompt bytes), uses the existing 16KB terminal input chunking and one existing 500ms submit delay, and adds no polling, provider listing, subprocess churn, hidden-pane wakeups, or renderer work."
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude adds one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; each later frame resets one timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added."
},
"promotionCriteria": [
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
@@ -8401,12 +8559,16 @@
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh"],
"coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, exact legacy Task/Dispatch/report-receipt verification, JSON rejection envelopes, and capability-negotiated Run-home settlement with lost-ack replay. Three isolated macOS Electron journeys launch fake Codex workers through the real RPC path. The compiled-CLI journey spawns fresh out/cli processes for rejected and accepted worker_done plus worker-release, then independently verifies runtime and SQLite state. The presentation and restart journeys assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, exact legacy Task/Dispatch/report-receipt verification, JSON rejection envelopes, and capability-negotiated Run-home settlement. The federation oracle drops the first terminal ACK, restarts Run-home twice, verifies replay through the existing polling path and the persisted acknowledgment watermark, races identical reports, accepts preexisting same-outcome ACK batches, rejects conflicting batches, and covers protocol v1/v2 completion across independently updated peers. A migration unit upgrades a v26 database with a zeroed watermark without replaying older migrations. Three isolated macOS Electron journeys launch fake Codex workers through the real RPC path. Distinct installed A/B artifacts plus live SSH, WSL, folder, Linux, and Windows cutover journeys remain explicit gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/11107#discussion_r3663321387",
"https://github.com/stablyai/orca/issues/13858"
"https://github.com/stablyai/orca/issues/13858",
"https://github.com/stablyai/orca/pull/14105",
"https://linear.app/stably/issue/STA-4020",
"https://linear.app/stably/issue/STA-4021",
"https://linear.app/stably/issue/STA-4022"
],
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. A worker_done caller may report success only after the owning runtime returns an explicit lifecycle verdict or authoritative reads prove that the exact Task, Dispatch, and worker report receipt settled the expected outcome. A federated worker may accept only the Run-home runtime's settlement verdict. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. A worker_done caller may report success only after the owning runtime returns an explicit lifecycle verdict or authoritative reads prove that the exact Task, Dispatch, and worker report receipt settled the expected outcome. Federated terminal settlement must remain replay-eligible until the worker durably acknowledges it, and identical same-outcome retries must converge idempotently. Independently updated clients and worker servers must preserve the negotiated protocol: current peers use Run-home lifecycle settlement, while protocol v1/v2 peers retain their legacy completion path without receiving newer-only fields. A federated worker may accept only the authority defined by its negotiated protocol. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
@@ -8414,6 +8576,7 @@
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
@@ -8436,6 +8599,7 @@
"src/main/runtime/rpc/methods/orchestration.test.ts",
"src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts",
"src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts",
"src/main/ssh/ssh-remote-orca-cli.test.ts",
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts",
"tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts",
@@ -8530,8 +8694,13 @@
"file": "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts",
"assertions": [
"a remote worker_done waits for Run-home settlement even when an older CLI omits the wait hint",
"older negotiated federation protocols reject before queueing",
"a committed settlement is replayed after an acknowledgment is lost",
"protocol v1/v2 clients can start fresh workers and complete success or failure on a current worker server",
"protocol v1/v2 completion ACK loss replays safely after a Run-home restart without newer-only wire fields",
"a current Run home still completes reports from legacy protocol v1/v2 worker servers",
"persisted protocol v1/v2 workers retain legacy-compatible completion after their worker server updates",
"the existing relay polling path retries a committed settlement across Run-home restarts until the worker acknowledgment watermark advances",
"identical pending reports coalesce to one relay and every caller receives the same Run-home verdict",
"preexisting same-outcome terminal ACK batches settle atomically while conflicting outcomes acknowledge nothing",
"an aborted settlement wait returns operation_unknown instead of accepted success",
"an already-aborted signal registers no settlement waiter",
"a Run-home terminal verdict settles the remote attachment and revokes its capability",
@@ -8540,6 +8709,14 @@
"a rejected report replays its exact verdict without mutating durable rejection mail twice"
]
},
{
"file": "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts",
"assertions": [
"a v26 database gains a zeroed durable home-to-worker acknowledgment watermark",
"a complete v26 database starts migration at v26 rather than replaying v7-v26",
"the migration advances schema version without losing the imported relay cursor"
]
},
{
"file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"assertions": ["federated worker placement explicitly sets activate=false"]
@@ -8582,6 +8759,69 @@
}
],
"evidenceRuns": [
{
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 6.02,
"summary": "The 70f1d52f mixed-version oracle passed all 21 cases. Protocol v1/v2 clients started fresh workers on a current server, completed success and failure with explicit legacy authority, and automatically retried a lost ACK after Run-home restart; current-protocol settlement and duplicate-report controls stayed green."
},
{
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
"result": "failed",
"durationSeconds": 4.21,
"summary": "The byte-identical 70f1d52f oracle failed 6 mixed-version cases while 15 controls passed when the fresh v1/v2 refusal was restored: success and failure through both negotiated versions plus both lost-ACK restart cases."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
"result": "failed",
"durationSeconds": 5.05,
"summary": "The byte-identical ac7bdf4e federation oracle failed 7 of 17 tests on affected 09ec516ae5: fresh v1/v2 work started before completion rejection, persisted v1/v2 work could not finish after update, same-outcome ACKs rejected, duplicate reports remained pending, and a dropped ACK was not replayed."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
"result": "failed",
"durationSeconds": 5.86,
"summary": "The same byte-identical oracle failed the same 7 of 17 tests on latest main 1136503c6a."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 4.28,
"summary": "The same byte-identical oracle passed all 17 tests on candidate 008f740161, including restart replay and both directions of v1/v2 update compatibility."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
"result": "failed",
"durationSeconds": 19.84,
"summary": "With the claimed production files restored to latest main in 3a15d3ed5d, the same byte-identical oracle returned to the same 7 failures while 10 unaffected cases still passed."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 0.47,
"summary": "A complete v26 database resolved migration start at v26, upgraded to v27, retained its imported cursor, and initialized the durable acknowledgment watermark to zero."
},
{
"date": "2026-08-12",
"runner": "local",
@@ -8692,11 +8932,11 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical compiled-CLI Electron oracle failed on latest main 5ea7df1a5b because a wrong worker identity exited zero with ok=true while Dispatch stayed dispatched, passed on combined candidate d697666ce8 with a nonzero ok=false rejection and explicit completed verdict for the exact worker, and reproduced the main failure when the claimed production files were disabled at merge-base 64aec94cb2. The earlier byte-identical service oracle was also red on main, green on candidate, red with authority propagation disabled, and green after restore. Distinct installed A/B and CI artifacts are still needed."
"evidence": "The byte-identical ac7bdf4e federation oracle failed 7 of 17 tests on affected 09ec516ae5 and latest main 1136503c6a, passed 17 of 17 on candidate 008f740161, and returned to the same 7 failures when the claimed production files were restored to latest main in 3a15d3ed5d. The follow-up byte-identical 70f1d52f oracle passes 21 of 21 with fresh v1/v2 compatibility and fails 6 mixed-version cases while 15 controls pass when only the refusal is restored. Together they prove dropped-ACK replay across Run-home restarts for current and legacy protocols, identical and preexisting same-outcome retry convergence, conflicting-outcome refusal, fresh and persisted old-client/current-server completion, current-client/old-server completion, and legacy wire isolation. The compiled-CLI Electron oracle separately failed on main 5ea7df1a5b, passed on candidate d697666ce8, and reproduced its original failure with the claimed production files disabled. Distinct installed A/B and CI artifacts are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Startup recovery performs one bounded controller inventory per legacy candidate, exact owner/identity checks, one background reveal, and a provider/renderer snapshot only when an adopted worker's in-memory tail is empty and terminal.read is explicitly requested. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup. Federated and explicitly background terminals are unchanged."
"evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Startup recovery performs one bounded controller inventory per legacy candidate, exact owner/identity checks, one background reveal, and a provider/renderer snapshot only when an adopted worker's in-memory tail is empty and terminal.read is explicitly requested. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup. Federation replay keeps the existing per-Dispatch polling cadence and existing transport queue limit, retaining a terminal Dispatch only while its imported cursor exceeds one persisted acknowledgment watermark. Each successful ACK adds one primary-key SQLite update; exact eligibility is a primary-key lookup, and identical pending terminal lookup is bounded by the existing 256-item per-Dispatch relay quota and pending-relay index. A version-aware schema sentinel keeps valid v26 databases on the direct v26-to-v27 migration path."
},
"promotionCriteria": [
"Collect 100 consecutive passes or 14 days of stable CI history on macOS, Linux, and Windows.",
@@ -11562,6 +11802,122 @@
],
"demotionRule": "Demote if any exit duration can evade the spawn budget, concurrent children or timers appear, cleanup retains resources, or a transient failure cannot recover within the budget."
},
{
"id": "runtime.headless-serve-graceful-signal-exit",
"title": "Packaged headless Linux serve exits cleanly after foreground signals",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-platform",
"layer": "electron-headless-quit-lifecycle",
"surfaces": ["packaged Linux AppImage", "headless orca serve", "owned Xvfb lifecycle"],
"platforms": ["linux"],
"providers": ["local-daemon"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "An Ubuntu 26.04 amd64 container extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, then exercises terminal-style foreground-process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same foreground AppRun identity contract.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/14109",
"https://linear.app/stably/issue/STA-4051"
],
"invariant": "After packaged foreground headless serve publishes structured readiness, one SIGINT or SIGTERM exits successfully without an Electron fatal trap or core evidence, releases the exact listener and owned Xvfb/process tree, and leaves an unrelated process identity untouched.",
"oracle": "For each signal, start a fresh unprivileged Ubuntu 26.04 container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot",
"shellcheck config/docker/headless-serve-shutdown/run-signal-case.sh",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64"
],
"testFiles": [
"src/main/startup/ensure-virtual-display.test.ts",
"config/scripts/headless-serve-shutdown-workflow.test.mjs",
"config/scripts/run-headless-serve-shutdown-docker.mjs",
"config/docker/headless-serve-shutdown/run-signal-case.sh"
],
"assertionRefs": [
{
"file": "src/main/startup/ensure-virtual-display.test.ts",
"assertions": [
"owned Xvfb starts with terminate-after-last-client semantics",
"owned Xvfb uses an independent process group so terminal Ctrl-C cannot preempt Electron teardown",
"owned Xvfb retains an early-process-exit guard until Electron is ready",
"owned Xvfb is not stopped from Electron's cancelable will-quit event",
"external displays and non-Linux startup remain untouched"
]
},
{
"file": "config/scripts/headless-serve-shutdown-workflow.test.mjs",
"assertions": [
"PR CI builds an x64 AppImage before invoking the packaged shutdown oracle",
"the documented systemd unit uses KillMode=mixed so graceful TERM targets Orca before its owned Xvfb"
]
},
{
"file": "config/scripts/run-headless-serve-shutdown-docker.mjs",
"assertions": [
"SIGINT and SIGTERM run in separate disposable containers",
"both signal failures are reported before the oracle exits",
"the exact AppImage SHA-256, entrypoint, and signal target are published",
"the launcher exec overlay isolates the related STA-4017 signal boundary"
]
},
{
"file": "config/docker/headless-serve-shutdown/run-signal-case.sh",
"assertions": [
"SIGINT reaches the isolated foreground process group while owned Xvfb stays in its own group",
"SIGTERM reaches the exact AppRun PID under the documented systemd KillMode=mixed policy",
"target and descendant identities are fenced by PID start ticks before signaling and residue checks"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-13",
"runner": "local",
"platform": "linux",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot",
"result": "passed",
"durationSeconds": 1,
"summary": "The focused startup and workflow contracts passed with owned-display termination semantics and native amd64 CI wiring."
},
{
"date": "2026-08-13",
"runner": "local",
"platform": "linux",
"command": "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64",
"result": "passed",
"durationSeconds": 48,
"summary": "The extracted candidate AppRun passed process-group SIGINT and systemd-mixed main-PID SIGTERM under Ubuntu 26.04 amd64 emulation with status zero, no fatal evidence, full listener/Xvfb/tree cleanup, and an unchanged canary identity."
}
],
"runtimeBudget": {
"p95Seconds": 240,
"scope": "two fresh Ubuntu 26.04 containers, one per foreground signal"
},
"flakeHistory": {
"status": "not-started",
"evidence": "The event- and identity-driven harness is new; soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical Docker oracle failed process-group SIGINT and main-PID SIGTERM on v1.4.180 AppImage af3b6e6a67fc, launch-day main AppImage 017ff5abc35a, and candidate-with-fix-disabled AppImage f409f58dd9c3 with wait status 133 plus Electron Failed to shutdown and SIGTRAP. The prior candidate 5b94b86a9879 passed PID signals but failed process-group SIGINT with status 133, proving Xvfb also needed an independent process group. Final candidate AppImage 5c82936043e4 passed process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM with status zero and complete cleanup. A control-group TERM that also targets Xvfb remains red, proving KillMode=mixed is required for the documented owned-Xvfb unit. Applying PR #14071's launcher exec semantics with PID delivery left v1.4.180 red and the candidate green, proving the launcher and Electron/Xvfb fixes are independent and composable."
},
"performanceBudget": {
"required": true,
"evidence": "The product change adds one standard Xvfb startup flag, isolates the existing Xvfb child from foreground process-group signals, replaces one Electron lifecycle listener with a process listener that is removed at ready, and adds no polling, timer, subprocess, IPC, provider fanout, renderer work, retained payload, native dependency, or recurring hot-path work. After ready, Xvfb exits from its existing client-disconnect path."
},
"promotionCriteria": [
"Collect 100 consecutive native amd64 CI or soak passes or 14 days without an unexplained flake.",
"Collect one native Ubuntu 26.04 AppRun signal run with foreground wait status zero for both signals.",
"Keep exact listener, Xvfb, descendant, fatal-evidence, and canary identity assertions green."
],
"knownGaps": [
"The local arm64 Docker host uses Rosetta for amd64 containers; native amd64 PR CI supplies the non-emulated repeat of the same extracted-AppRun PID contract.",
"Ubuntu 20.04 is covered by the packaged native-binary glibc floor gate; this lifecycle journey runs on the reported Ubuntu 26.04 topology.",
"Systemd units that override or omit the documented KillMode=mixed policy can still terminate owned Xvfb before Electron disconnects.",
"The harness exercises foreground headless Linux shutdown and does not replace desktop, updater, SSH, or detached PTY lifecycle coverage."
],
"demotionRule": "Keep experimental or demote if either signal traps, returns nonzero, retains its listener/Xvfb/run-owned process identity, touches the unrelated canary, or the focused gate flakes without an identified product or harness defect."
},
{
"id": "ssh-managed-hooks.node18-runtime-compatibility",
"title": "SSH managed-hook companions load and install hooks on Node 18",
@@ -22,7 +22,7 @@ const BUDGETS = {
maxTimerDriftMs: 150,
// Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts
// so injected multi-pane redraw rows are not judged against the unloaded ceiling.
maxTimerDriftUnderLoadMs: 2_500,
maxTimerDriftUnderLoadMs: 3_500,
maxScrollLatencyMs: 150,
maxRestoreLatencyMs: 1000,
maxRendererQueuedChars: 2 * 1024 * 1024,
@@ -132,14 +132,14 @@ describe('check-terminal-perf-report-budgets', () => {
it('fails multi-pane redraw scenarios that exceed the under-load timer-drift budget', () => {
const failPath = writeReport(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=2501.0ms'].join(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=3501.0ms'].join(
' '
),
'opencode-cross-workspace-typing'
)
const failResult = runChecker(failPath)
expect(failResult.status).toBe(1)
expect(failResult.stderr).toContain('timer drift 2501ms exceeded budget 2500ms')
expect(failResult.stderr).toContain('timer drift 3501ms exceeded budget 3500ms')
})
it('fails malformed metric values instead of treating them as absent', () => {
@@ -1,6 +1,8 @@
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
import {
@@ -18,6 +20,7 @@ import {
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
const execFileAsync = promisify(execFile)
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
@@ -92,6 +95,56 @@ describe('bundled skill guide generator', () => {
}
})
it('uses the exported recipe id variable in per-workspace environment examples', async () => {
const source = await readFile(
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
'utf8'
)
expect(source).toContain('ORCA_RECIPE_ID')
expect(source).not.toContain('ORCA_VM_RECIPE_ID')
expect(source).toContain('recipe_id="${recipe_id//./-}"')
expect(source).toContain('max_recipe_id_length=$((128 - ${#instance_id} - 6))')
expect(source).toContain('name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"')
})
it.skipIf(process.platform === 'win32')(
'keeps Vercel sandbox names valid while preserving the instance suffix',
async () => {
const source = await readFile(
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
'utf8'
)
const startMarker = 'recipe_id="${ORCA_RECIPE_ID:-vercel-sandbox}"'
const endMarker = 'name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"'
const start = source.indexOf(startMarker)
const endStart = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(endStart).toBeGreaterThan(start)
const script = `${source.slice(start, endStart + endMarker.length)}\nprintf '%s' "$name"`
const renderName = async (recipeId, instanceId) =>
(
await execFileAsync('bash', ['-u', '-c', script], {
env: { ...process.env, ORCA_RECIPE_ID: recipeId, ORCA_VM_INSTANCE_ID: instanceId }
})
).stdout
const instanceId = 'orca-123e4567-e89b-12d3-a456-426614174000'
const dotted = await renderName('provider.cloud_sandbox', instanceId)
const maximum = await renderName(`a${'.'.repeat(63)}`, instanceId)
const longInstanceId = 'i'.repeat(100)
const capped = await renderName(
'provider.cloud_sandbox.with.a.long.recipe.identifier',
longInstanceId
)
expect(dotted).toBe(`orca-provider-cloud_sandbox-${instanceId}`)
expect(maximum).toMatch(/^[a-zA-Z0-9_-]{1,128}$/u)
expect(capped).toHaveLength(128)
expect(capped.endsWith(`-${longInstanceId}`)).toBe(true)
}
)
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8')
function readSystemdUnitBlocks(doc, unitName) {
const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return [...doc.matchAll(new RegExp(`^# /etc/systemd/system/${escapedUnitName}$`, 'gm'))].map(
(match) => {
const start = match.index + match[0].length
const end = doc.indexOf('```', start)
const nextUnitHeaderOffset = doc.slice(start).search(/^# \/etc\/systemd\/system\/.+$/m)
const nextUnitHeader = nextUnitHeaderOffset === -1 ? -1 : start + nextUnitHeaderOffset
if (end === -1 || (nextUnitHeader !== -1 && end > nextUnitHeader)) {
throw new Error(`Missing closing code fence for ${unitName}`)
}
return doc.slice(start, end)
}
)
}
describe('headless serve shutdown PR gate', () => {
it('reads only exact, closed systemd unit blocks', () => {
expect(
readSystemdUnitBlocks('# /etc/systemd/system/orca-serveXservice\n```', 'orca-serve.service')
).toEqual([])
expect(() =>
readSystemdUnitBlocks('# /etc/systemd/system/orca-serve.service\n', 'orca-serve.service')
).toThrow('Missing closing code fence for orca-serve.service')
expect(() =>
readSystemdUnitBlocks(
'# /etc/systemd/system/orca-serve.service\n' +
'KillMode=mixed\n' +
'# /etc/systemd/system/other.service\n```',
'orca-serve.service'
)
).toThrow('Missing closing code fence for orca-serve.service')
})
it('packages an x64 AppImage before running the Docker signal oracle', () => {
const steps = workflow.jobs.package.steps
const packageStep = steps.find((step) => step.name === 'Package unpacked app')
const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown')
expect(packageStep.run).toContain('--linux AppImage --x64 --publish never')
expect(shutdownStep.run).toBe(
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage'
)
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep))
})
it('keeps owned Xvfb alive during the documented systemd graceful stop', () => {
const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service')
const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit))
const managedXvfbUnits = serveUnits.filter((unit) => /^Environment=DISPLAY=/m.test(unit))
expect(ownedXvfbUnits).toHaveLength(1)
expect(ownedXvfbUnits[0]).toMatch(/^ExecStart=.*orca-linux\.AppImage serve.*$/m)
expect(ownedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m)
expect(managedXvfbUnits).toHaveLength(1)
expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m)
})
})
@@ -115,10 +115,6 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
zh: '评审中',
ja: 'レビュー中'
},
'auto.components.skills.SkillsPage.b088e0785d': {
zh: '测试版',
ja: 'ベータ'
},
'auto.components.status.bar.WorkspaceSpaceCompactPanel.c361440dc0': {
zh: '测试版',
ja: 'ベータ'
-10
View File
@@ -312,11 +312,6 @@ const BASE_LOCALE_KEY_OVERRIDES = {
zh: '显示中',
ja: '表示中'
},
'auto.components.skills.SkillsPage.a68dee6a32': {
ko: '스킬 검색',
zh: '搜索技能',
ja: 'スキルを検索'
},
'auto.components.editor.RichMarkdownSlashMenu.550189b06c': {
ko: '블록 검색',
zh: '搜索块',
@@ -435,11 +430,6 @@ const BASE_LOCALE_KEY_OVERRIDES = {
zh: '代理位置',
ja: 'Agent の場所'
},
'auto.components.skills.SkillsPage.38e0951c3a': {
ko: '에이전트 스킬',
zh: '代理技能',
ja: 'Agent のスキル'
},
'auto.components.sidebar.SidebarNav.e518f544b1': {
ko: '감지된 에이전트 없음',
zh: '未检测到代理',
@@ -4445,9 +4445,6 @@
"auto.components.sidebar.worktree.list.groups.6798dc7c94": {
"ko": "리뷰 중"
},
"auto.components.skills.SkillsPage.b088e0785d": {
"ko": "베타"
},
"auto.components.source.control.SourceControlActionVariableChips.4bf6d88039": {
"ko": "(비어 있음)"
},
@@ -221,16 +221,20 @@ describe('orchestration skill guidance', () => {
expect(skill).toContain('the named owner edits files and creates the PR')
})
it('keeps worker_done post-completion guidance idle instead of polling', () => {
it('keeps post-completion workers idle without subordinating the user', () => {
const skill = readSkill()
const agentGuidance = getSection(skill, 'Agent Guidance')
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
expect(agentGuidance).toContain('After sending `worker_done`, end that dispatched turn')
expect(agentGuidance).toContain('idle at the agent prompt')
expect(agentGuidance).toContain('Do not autonomously start more work, poll')
expect(agentGuidance).toContain('A direct user instruction takes precedence')
expect(agentGuidance).toContain('follow it without coordinator approval or a fresh Dispatch')
expect(agentGuidance).toContain('never refuse it because of worker/coordinator roles')
expect(agentGuidance).toContain("do not reuse the settled Dispatch's lifecycle IDs")
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
'A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block'
)
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
@@ -305,7 +309,7 @@ describe('orchestration skill guidance', () => {
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
)
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
'Do not autonomously start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
})
@@ -568,16 +568,18 @@ describe('Electron runtime package contract', () => {
'pnpm run test:e2e:terminal-rendering-golden'
)
expect(goldenRunSteps.get('linux')?.run).toContain(
'pnpm run test:e2e:posix-profile-index-golden'
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
expect(goldenRunSteps.get('mac')?.run).toContain(
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
expect(goldenRunSteps.get('windows')).toMatchObject({
if: "runner.os == 'Windows'",
shell: 'pwsh'
})
expect(goldenRunSteps.get('windows').run).toContain(
'pnpm run test:e2e:windows-fresh-startup-golden'
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
)
expect(goldenWorkflow.on.pull_request).toBeUndefined()
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
@@ -592,12 +594,16 @@ describe('Electron runtime package contract', () => {
(step) => step.name === 'Run terminal rendering golden on Linux'
)
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
expect(releaseLinuxRunStep.run).toContain(
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
const releaseMacRunStep = releaseGoldenJob.steps.find(
(step) => step.name === 'Run terminal rendering golden on macOS'
)
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
expect(releaseMacRunStep.run).toContain(
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
const releaseWindowsRunStep = releaseGoldenJob.steps.find(
(step) => step.name === 'Run fresh-startup golden on Windows'
)
@@ -605,7 +611,9 @@ describe('Electron runtime package contract', () => {
if: "runner.os == 'Windows'",
shell: 'pwsh'
})
expect(releaseWindowsRunStep.run).toContain('pnpm run test:e2e:windows-fresh-startup-golden')
expect(releaseWindowsRunStep.run).toContain(
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
)
expect(releaseEvidenceJob['continue-on-error']).toBe(true)
expect(
releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort()
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const args = process.argv.slice(2)
const appImageArg = valueAfter('--appimage')
const platform = valueAfter('--platform') ?? 'linux/amd64'
const signalTarget = valueAfter('--signal-target') ?? 'app'
const entrypoint = valueAfter('--entrypoint') ?? 'app'
const intDelivery = valueAfter('--int-delivery') ?? 'foreground-process-group'
const launcherExecOverlay = args.includes('--launcher-exec-overlay')
if (!appImageArg) {
fail('Usage: run-headless-serve-shutdown-docker.mjs --appimage /path/to/orca.AppImage')
}
if (!['app', 'serving-electron'].includes(signalTarget)) {
fail(`Unsupported --signal-target: ${signalTarget}`)
}
if (!['app', 'launcher'].includes(entrypoint)) {
fail(`Unsupported --entrypoint: ${entrypoint}`)
}
if (!['pid', 'foreground-process-group'].includes(intDelivery)) {
fail(`Unsupported --int-delivery: ${intDelivery}`)
}
if (intDelivery === 'foreground-process-group' && signalTarget !== 'app') {
fail('--int-delivery foreground-process-group requires --signal-target app')
}
if (launcherExecOverlay && entrypoint !== 'launcher') {
fail('--launcher-exec-overlay requires --entrypoint launcher')
}
const appImage = resolve(appImageArg)
const shutdownDockerDirectory = resolve('config', 'docker', 'headless-serve-shutdown')
const shutdownDockerfile = resolve(shutdownDockerDirectory, 'Dockerfile')
if (!existsSync(appImage)) {
fail(`AppImage not found: ${appImage}`)
}
const suffix = `${process.pid}-${Date.now()}`
const image = `orca-headless-serve-shutdown:${suffix}`
const artifactVolume = `orca-headless-serve-shutdown-${suffix}`
const sha256 = createHash('sha256').update(readFileSync(appImage)).digest('hex')
try {
docker([
'build',
'--platform',
platform,
'-f',
shutdownDockerfile,
'-t',
image,
shutdownDockerDirectory
])
docker(['volume', 'create', artifactVolume])
docker([
'run',
'--rm',
'--platform',
platform,
'--entrypoint',
'bash',
'-v',
`${appImage}:/input/orca.AppImage:ro`,
'-v',
`${artifactVolume}:/artifacts`,
image,
'-lc',
[
'7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null',
launcherExecOverlay
? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide"
: ':',
'chmod -R a+rX /artifacts/root'
].join(' && ')
])
console.log(
JSON.stringify({
type: 'appimage_under_test',
appImage,
sha256,
platform,
signalTarget,
entrypoint,
intDelivery,
launcherExecOverlay
})
)
const failedSignals = []
for (const signal of ['INT', 'TERM']) {
const result = docker(
[
'run',
'--rm',
'--init',
'--platform',
platform,
'--shm-size',
'256m',
'--name',
`orca-headless-serve-shutdown-${signal.toLowerCase()}-${suffix}`,
'-e',
`ORCA_SIGNAL_TARGET=${signalTarget}`,
'-e',
`ORCA_TEST_ENTRYPOINT=${entrypoint}`,
'-e',
`ORCA_INT_DELIVERY=${intDelivery}`,
'-v',
`${artifactVolume}:/artifacts:ro`,
image,
signal
],
{ allowFailure: true }
)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status !== 0) {
failedSignals.push(`${signal}:${result.status}`)
}
}
if (failedSignals.length > 0) {
fail(`Shutdown oracle failed: ${failedSignals.join(', ')}`)
}
console.log('Headless serve packaged shutdown Docker validation passed.')
} finally {
docker(['volume', 'rm', artifactVolume], { allowFailure: true })
docker(['image', 'rm', image], { allowFailure: true })
}
function valueAfter(flag) {
const index = args.indexOf(flag)
return index === -1 ? null : (args[index + 1] ?? null)
}
function docker(dockerArgs, options = {}) {
const result = spawnSync('docker', dockerArgs, {
cwd: process.cwd(),
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.error) {
throw result.error
}
if (result.status !== 0 && !options.allowFailure) {
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
fail(`docker ${dockerArgs[0]} failed with status ${result.status}`)
}
return result
}
function fail(message) {
console.error(message)
process.exitCode = 1
throw new Error(message)
}
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { performance } from 'node:perf_hooks'
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree/sort-order-update.ts'
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
const WORKTREE_COUNT = 655
+1
View File
@@ -56,6 +56,7 @@
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
"../src/main/codex/managed-home-shell-preflight.ts",
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/codex-accounts/wsl-codex-command.ts",
"../src/main/codex-cli/command.ts",
+1
View File
@@ -5,6 +5,7 @@
"../src/renderer/src/**/*",
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/preload/api/**/*",
"../src/shared/**/*",
"../src/main/gitlab/mappers.ts",
"../src/main/ipc/worktree-branch-name.ts",
+1
View File
@@ -5,6 +5,7 @@
"../src/renderer/src/**/*",
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/preload/api/**/*",
"../src/shared/**/*"
],
"compilerOptions": {
+5
View File
@@ -166,6 +166,7 @@ Environment=LIBGL_ALWAYS_SOFTWARE=1
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
StandardOutput=journal
StandardError=journal
KillMode=mixed
Restart=on-failure
RestartPreventExitStatus=3
RestartSec=5
@@ -177,6 +178,10 @@ WantedBy=multi-user.target
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
clients should use.
`KillMode=mixed` sends the graceful stop signal only to Orca's main process,
then retains systemd's cgroup-wide `SIGKILL` fallback if shutdown times out.
This lets Orca keep its owned Xvfb alive until Electron disconnects cleanly.
Exit status `3` means another process already owns this userData profile, so
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
cannot succeed. Any other permanent startup fault is capped at 5 starts per
+4 -4
View File
@@ -25,7 +25,7 @@ import { loadHosts, updateLastConnected } from '../../../src/transport/host-stor
import { removeHostAndCloseClient } from '../../../src/transport/host-removal-lifecycle'
import {
useHostClient,
useCloseHost,
useForgetHostClient,
useForceReconnect
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
@@ -148,7 +148,7 @@ export function HostScreen({
const repoMetadataFetchedAtRef = useRef(0)
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
const newWorktreeModalVisibleRef = useRef(false)
const closeHostClient = useCloseHost()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
@@ -636,14 +636,14 @@ export function HostScreen({
return
}
try {
await removeHostAndCloseClient(hostId, closeHostClient)
await removeHostAndCloseClient(hostId, forgetHostClient)
leaveHost()
} catch {
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
setConfirmRemoveHost(true)
Alert.alert('Could not remove host', 'Please try again.')
}
}, [hostId, leaveHost, closeHostClient])
}, [hostId, leaveHost, forgetHostClient])
const navigateFromHostList = useCallback(
(target: string) => {
+17 -40
View File
@@ -127,15 +127,6 @@ import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import {
addLegacyTerminalAttributionDisableRequest,
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
withLegacyTerminalAttributionDisabledEnv
} from '../../../../../src/shared/legacy-terminal-attribution-env'
import {
assertMobileTerminalAttributionDisableSupported,
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
} from '../../../../src/session/mobile-terminal-attribution-compat'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
import {
@@ -3674,27 +3665,20 @@ export default function SessionScreen() {
.slice(2, 10)}`
try {
const authority = await assertMobileTerminalAttributionDisableSupported(client)
const response = await client.sendRequest(
'session.tabs.createTerminal',
{
worktree: `id:${worktreeId}`,
afterTabId: activeSessionTabId ?? undefined,
clientMutationId,
...(options?.startupCommand ? { command: options.startupCommand } : {}),
...(options?.startupCommandDelivery
? { startupCommandDelivery: options.startupCommandDelivery }
: {}),
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
...(agent ? { agent } : {}),
activate: false,
select: true,
navigation: 'caller'
},
{ ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId }
)
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
afterTabId: activeSessionTabId ?? undefined,
clientMutationId,
...(options?.startupCommand ? { command: options.startupCommand } : {}),
...(options?.startupCommandDelivery
? { startupCommandDelivery: options.startupCommandDelivery }
: {}),
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
...(agent ? { agent } : {}),
activate: false,
select: true,
navigation: 'caller'
})
if (response.ok) {
const result = (response as RpcSuccess).result as TerminalCreateResult
const created = result.tab
@@ -3794,17 +3778,10 @@ export default function SessionScreen() {
showToast(message, 1800)
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : null
const message =
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
? errorMessage
: (options?.errorToast ?? errorMessage ?? 'Failed to create terminal')
} catch {
const message = options?.errorToast ?? 'Failed to create terminal'
setCreateError(message)
if (
options?.errorToast ||
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
) {
if (options?.errorToast) {
triggerError()
showToast(message, 1800)
}
+3 -3
View File
@@ -119,11 +119,11 @@ import {
isIterationCurrent,
sortRows,
type ProjectGroup
} from '../../../../src/shared/github-project-group-sort'
} from '../../../../src/shared/github/project-group-sort'
import type {
GitHubProjectSortDirection,
GitHubProjectTable as SharedGitHubProjectTable
} from '../../../../src/shared/github-project-types'
} from '../../../../src/shared/github/project-types'
import {
CROSS_REPO_DISPLAY_LIMIT,
isGitHubWorkItemsSshRemoteRequiredError,
@@ -161,7 +161,7 @@ import type { HostedReviewDecision } from '../../../../src/shared/hosted-review'
import {
githubProjectHost,
githubProjectIdentityKey as githubProjectKey
} from '../../../../src/shared/github-project-identity'
} from '../../../../src/shared/github/project-identity'
type RepoSummary = {
id: string
+10 -4
View File
@@ -25,7 +25,12 @@ import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home
import type { RpcClient } from '../src/transport/rpc-client'
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
import {
useDisconnectHostClient,
useForceReconnect,
useForgetHostClient,
usePrimeHosts
} from '../src/transport/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import {
resolveHomeHostConnectionState,
@@ -254,7 +259,8 @@ export default function HomeScreen() {
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
)
const closeHostClient = useCloseHost()
const disconnectHostClient = useDisconnectHostClient()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
// Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start.
@@ -643,7 +649,7 @@ export default function HomeScreen() {
}
const hostToRemove = confirmRemove
try {
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
await removeHostAndCloseClient(hostToRemove.id, forgetHostClient)
setConfirmRemove(null)
setHostCatalog(await loadHostCatalog())
} catch {
@@ -949,7 +955,7 @@ export default function HomeScreen() {
: false,
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
onDisconnect: disconnectHostClient,
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
+4 -6
View File
@@ -9,7 +9,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { useRefreshHostClient } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
@@ -28,7 +28,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
export default function PairConfirmScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const refreshHostClient = useRefreshHostClient()
const insets = useSafeAreaInsets()
const params = useLocalSearchParams<{ code?: string }>()
const [status, setStatus] = useState<Status>('awaiting-confirm')
@@ -112,10 +112,8 @@ export default function PairConfirmScreen() {
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
// Refresh any cached client from the newly persisted pairing profile.
refreshHostClient(hostId)
const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
+4 -6
View File
@@ -18,7 +18,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { useRefreshHostClient } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -47,7 +47,7 @@ function Step({ number, text }: { number: number; text: string }) {
export default function PairScanScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const refreshHostClient = useRefreshHostClient()
const insets = useSafeAreaInsets()
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
@@ -156,10 +156,8 @@ export default function PairScanScreen() {
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
// Refresh any cached client from the newly persisted pairing profile.
refreshHostClient(hostId)
const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
@@ -1,6 +1,6 @@
import { isPathInsideOrEqual, isRuntimePathAbsolute } from '../../../src/shared/cross-platform-path'
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree/id'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import type { Worktree } from '../worktree/workspace-list-types'
@@ -0,0 +1,91 @@
import { Platform, StyleSheet, Text, TextInput, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { compactMobileBrowserFileAddress } from './browser-url'
type Props = {
disabled: boolean
focused: boolean
onBlur: () => void
onChangeText: (value: string) => void
onFocus: () => void
onSubmit: () => void
value: string
}
export function MobileBrowserAddressField({
disabled,
focused,
onBlur,
onChangeText,
onFocus,
onSubmit,
value
}: Props): React.JSX.Element {
const fileLabel = focused ? null : compactMobileBrowserFileAddress(value)
const selection = focused ? undefined : { start: 0, end: 0 }
return (
<View style={styles.field}>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
onFocus={onFocus}
onBlur={onBlur}
onSubmitEditing={onSubmit}
selectTextOnFocus
selection={selection}
autoCapitalize="none"
autoCorrect={false}
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
numberOfLines={1}
returnKeyType="go"
placeholder="URL"
placeholderTextColor={colors.textMuted}
editable={!disabled}
/>
{fileLabel ? (
<View pointerEvents="none" style={styles.fileLabelHost}>
<Text style={styles.fileLabel} numberOfLines={1} ellipsizeMode="middle">
{fileLabel}
</Text>
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
field: {
flex: 1,
minWidth: 0,
height: 28
},
input: {
flex: 1,
minWidth: 0,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
paddingHorizontal: spacing.sm,
paddingVertical: 0,
fontSize: 12,
lineHeight: 16,
includeFontPadding: false,
textAlignVertical: 'center',
fontFamily: typography.monoFamily
},
fileLabelHost: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
paddingHorizontal: spacing.sm,
borderRadius: radii.input,
backgroundColor: colors.bgRaised
},
fileLabel: {
color: colors.textPrimary,
fontSize: 12,
lineHeight: 16,
fontFamily: typography.monoFamily
}
})
+11 -36
View File
@@ -9,7 +9,6 @@ import {
Image,
PanResponder,
PixelRatio,
Platform,
Pressable,
StyleSheet,
Text,
@@ -54,6 +53,7 @@ import {
type BrowserZoomState
} from './browser-touch-geometry'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
export type MobileBrowserTab = {
@@ -140,7 +140,7 @@ export function MobileBrowserPane({
onToast
}: MobileBrowserPaneProps) {
const [browserViewMode, setBrowserViewMode] = useState<MobileBrowserViewMode>(() =>
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId, tab.url)
)
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
@@ -265,6 +265,10 @@ export function MobileBrowserPane({
resetBrowserZoomState()
}, [resetBrowserZoomState, tab.browserPageId, tab.url])
useEffect(() => {
setBrowserViewMode(getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId, tab.url))
}, [tab.browserPageId, tab.url, worktreeId])
const pageParams = useCallback(() => {
if (!tab.browserPageId) {
return null
@@ -1018,10 +1022,6 @@ export function MobileBrowserPane({
)
const controlsDisabled = !client || !tab.browserPageId || screencastSupported !== true
const addressSelection = useMemo(
() => (addressFocused ? undefined : { start: 0, end: 0 }),
[addressFocused]
)
const goBack = useCallback(() => {
if (controlsDisabled || !tab.canGoBack) {
return
@@ -1045,8 +1045,7 @@ export function MobileBrowserPane({
if (browserViewMode === mode) {
return
}
// Why: browser panes can remount during normal tab/workspace navigation;
// keep a page-scoped choice while new browser pages still default to Web.
// Why: preserve explicit page-scoped choices across normal browser pane remounts.
saveMobileBrowserViewMode(worktreeId, tab.browserPageId, mode)
setBrowserViewMode(mode)
resetBrowserZoomState()
@@ -1104,23 +1103,14 @@ export function MobileBrowserPane({
>
<RefreshCw size={15} color={buttonColor(!controlsDisabled)} />
</MobileBrowserToolbarIconButton>
<TextInput
style={styles.addressInput}
<MobileBrowserAddressField
value={addressValue}
onChangeText={setAddressValue}
onFocus={() => setAddressFocused(true)}
onBlur={() => setAddressFocused(false)}
onSubmitEditing={() => void navigateToAddress()}
selectTextOnFocus
selection={addressSelection}
autoCapitalize="none"
autoCorrect={false}
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
numberOfLines={1}
returnKeyType="go"
placeholder="URL"
placeholderTextColor={colors.textMuted}
editable={!controlsDisabled}
onSubmit={() => void navigateToAddress()}
focused={addressFocused}
disabled={controlsDisabled}
/>
<MobileBrowserViewModeSwitch
disabled={controlsDisabled}
@@ -1494,21 +1484,6 @@ const styles = StyleSheet.create({
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
addressInput: {
flex: 1,
minWidth: 0,
height: 28,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
paddingHorizontal: spacing.sm,
paddingVertical: 0,
fontSize: 12,
lineHeight: 16,
includeFontPadding: false,
textAlignVertical: 'center',
fontFamily: typography.monoFamily
},
viewport: {
flex: 1,
minHeight: 0,
@@ -1,5 +1,6 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { Pressable, StyleSheet, View } from 'react-native'
import { Monitor, Smartphone, type LucideIcon } from 'lucide-react-native'
import { colors, radii } from '../theme/mobile-theme'
import type { MobileBrowserViewMode } from './browser-screencast-request'
type Props = {
@@ -8,9 +9,9 @@ type Props = {
onChange: (mode: MobileBrowserViewMode) => void
}
const VIEW_MODES: { id: MobileBrowserViewMode; label: string }[] = [
{ id: 'web', label: 'Web' },
{ id: 'mobile', label: 'Mobile' }
const VIEW_MODES: { id: MobileBrowserViewMode; label: string; icon: LucideIcon }[] = [
{ id: 'web', label: 'Web', icon: Monitor },
{ id: 'mobile', label: 'Mobile', icon: Smartphone }
]
export function MobileBrowserViewModeSwitch({
@@ -23,6 +24,7 @@ export function MobileBrowserViewModeSwitch({
{VIEW_MODES.map((mode) => (
<ViewModeButton
key={mode.id}
Icon={mode.icon}
label={mode.label}
selected={value === mode.id}
disabled={disabled}
@@ -34,11 +36,13 @@ export function MobileBrowserViewModeSwitch({
}
function ViewModeButton({
Icon,
disabled,
label,
onPress,
selected
}: {
Icon: LucideIcon
disabled?: boolean
label: string
onPress: () => void
@@ -58,7 +62,7 @@ function ViewModeButton({
accessibilityState={{ selected, disabled }}
accessibilityLabel={`Show ${label.toLowerCase()} website view`}
>
<Text style={[styles.buttonText, selected && styles.buttonTextSelected]}>{label}</Text>
<Icon size={14} color={selected ? colors.bgBase : colors.textSecondary} />
</Pressable>
)
}
@@ -74,11 +78,10 @@ const styles = StyleSheet.create({
},
button: {
minHeight: 24,
minWidth: 52,
width: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.sm
borderRadius: radii.button
},
buttonPressed: {
backgroundColor: colors.borderSubtle
@@ -86,14 +89,6 @@ const styles = StyleSheet.create({
buttonSelected: {
backgroundColor: colors.textPrimary
},
buttonText: {
color: colors.textSecondary,
fontSize: 12,
fontWeight: '600'
},
buttonTextSelected: {
color: colors.bgBase
},
disabled: {
opacity: 0.35
}
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { normalizeBrowserUrl } from './browser-url'
import { compactMobileBrowserFileAddress, normalizeBrowserUrl } from './browser-url'
describe('normalizeBrowserUrl', () => {
it('keeps localhost-style addresses as http URLs', () => {
@@ -14,3 +14,19 @@ describe('normalizeBrowserUrl', () => {
expect(normalizeBrowserUrl('github.com/stablyai/orca')).toBe('https://github.com/stablyai/orca')
})
})
describe('compactMobileBrowserFileAddress', () => {
it('shows the decoded filename for local and Windows-style file URLs', () => {
expect(compactMobileBrowserFileAddress('file:///Users/me/reports/status%20report.html')).toBe(
'file: …/status report.html'
)
expect(compactMobileBrowserFileAddress('file:///C:/Users/me/report.htm')).toBe(
'file: …/report.htm'
)
})
it('does not compact ordinary or malformed URLs', () => {
expect(compactMobileBrowserFileAddress('https://example.com/report.html')).toBeNull()
expect(compactMobileBrowserFileAddress('not a url')).toBeNull()
})
})
+21
View File
@@ -31,6 +31,27 @@ export function displayBrowserUrl(value: string | null | undefined): string {
return isBlankBrowserUrl(trimmed) ? 'about:blank' : trimmed
}
export function compactMobileBrowserFileAddress(value: string): string | null {
try {
const url = new URL(value)
if (url.protocol !== 'file:') {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
const encodedFilename = segments.at(-1)
if (!encodedFilename) {
return 'file:'
}
try {
return `file: …/${decodeURIComponent(encodedFilename)}`
} catch {
return `file: …/${encodedFilename}`
}
} catch {
return null
}
}
export function isBlankBrowserUrl(value: string | null | undefined): boolean {
const trimmed = value?.trim() ?? ''
return !trimmed || trimmed === 'about:blank' || trimmed.startsWith('data:text/html')
@@ -15,6 +15,28 @@ describe('mobile browser view mode state', () => {
expect(getInitialMobileBrowserViewMode('worktree-1', null)).toBe('web')
})
it('defaults local HTML reports to mobile view', () => {
expect(
getInitialMobileBrowserViewMode(
'folder:workspace-1',
'page-1',
'file:///Users/me/report%20one.HTML?generated=1'
)
).toBe('mobile')
expect(
getInitialMobileBrowserViewMode('worktree-1', 'page-2', 'file:///C:/Users/me/report.htm')
).toBe('mobile')
})
it('keeps remote HTML URLs and non-HTML files in web view', () => {
expect(
getInitialMobileBrowserViewMode('worktree-1', 'page-1', 'https://example.com/report.html')
).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2', 'file:///repo/notes.txt')).toBe(
'web'
)
})
it('restores the last mode for the same browser page after remount', () => {
saveMobileBrowserViewMode('worktree-1', 'page-1', 'mobile')
@@ -22,4 +44,12 @@ describe('mobile browser view mode state', () => {
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web')
})
it('preserves an explicit web choice for a local HTML report', () => {
saveMobileBrowserViewMode('worktree-1', 'page-1', 'web')
expect(
getInitialMobileBrowserViewMode('worktree-1', 'page-1', 'file:///repo/report.html')
).toBe('web')
})
})
@@ -5,13 +5,23 @@ const browserViewModeByPageKey = new Map<string, MobileBrowserViewMode>()
export function getInitialMobileBrowserViewMode(
worktreeId: string,
browserPageId: string | null
browserPageId: string | null,
url = 'about:blank'
): MobileBrowserViewMode {
const pageKey = makeBrowserViewModePageKey(worktreeId, browserPageId)
if (!pageKey) {
return defaultMobileBrowserViewMode(url)
}
return browserViewModeByPageKey.get(pageKey) ?? defaultMobileBrowserViewMode(url)
}
function defaultMobileBrowserViewMode(url: string): MobileBrowserViewMode {
try {
const parsed = new URL(url)
return parsed.protocol === 'file:' && /\.html?$/i.test(parsed.pathname) ? 'mobile' : 'web'
} catch {
return 'web'
}
return browserViewModeByPageKey.get(pageKey) ?? 'web'
}
export function saveMobileBrowserViewMode(
@@ -226,13 +226,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
it('creates a fresh terminal and sends the command with Enter', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({
ok: true,
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
@@ -256,20 +249,13 @@ describe('resumeAiVaultSessionInTerminal', () => {
navigation: 'caller'
})
).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' })
expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
timeoutMs: 30_000,
budgetSpansConnect: true
})
expect(sendRequest).toHaveBeenNthCalledWith(
2,
1,
'session.tabs.createTerminal',
{
worktree: 'id:worktree-1',
env: {
ANTHROPIC_BASE_URL: 'http://localhost:3000',
ORCA_ATTRIBUTION_BYPASS: '1'
},
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'],
env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
launchConfig: {
agentCommand: 'claude',
agentArgs: '',
@@ -283,14 +269,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
},
// Why: a socket drop mid-resume must reject within the request timeout
// instead of parking on the reconnect waiter with the spinner pinned.
{
timeoutMs: RESUME_RPC_TIMEOUT_MS,
budgetSpansConnect: true,
expectedRuntimeId: 'runtime'
}
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
expect(sendRequest).toHaveBeenNthCalledWith(
3,
2,
'terminal.send',
{
terminal: 'pty-1',
@@ -302,35 +284,17 @@ describe('resumeAiVaultSessionInTerminal', () => {
})
it('throws when terminal creation fails', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({
ok: false,
error: { message: 'no terminal' }
})
const sendRequest = vi.fn().mockResolvedValueOnce({
ok: false,
error: { message: 'no terminal' }
})
await expect(
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
).rejects.toThrow('no terminal')
})
it('throws when the created terminal response is malformed', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
await expect(
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
).rejects.toThrow('Created terminal response was invalid')
@@ -339,13 +303,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
it('throws when terminal send fails or is locked', async () => {
const failedSend = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({
ok: true,
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
@@ -359,13 +316,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
const lockedSend = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({
ok: true,
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
@@ -377,17 +327,6 @@ describe('resumeAiVaultSessionInTerminal', () => {
})
).rejects.toThrow('Terminal input is locked')
})
it('refuses an old host before creating a resume terminal', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({ ok: true, result: { appVersion: '1.4.89' } })
await expect(
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
).rejects.toThrow('Update the host and try again')
expect(sendRequest).toHaveBeenCalledTimes(1)
})
})
describe('createMobileAiVaultResumeMutationRegistry', () => {
+3 -13
View File
@@ -16,10 +16,6 @@ import { normalizeAiVaultResumeFilePath } from '../../../src/shared/ai-vault-res
import type { TuiAgent } from '../../../src/shared/types'
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell'
import {
addLegacyTerminalAttributionDisableRequest,
withLegacyTerminalAttributionDisabledEnv
} from '../../../src/shared/legacy-terminal-attribution-env'
import type { RpcClient } from '../transport/rpc-client'
import {
readMobileReviewCreatedTerminal,
@@ -27,7 +23,6 @@ import {
type MobileReviewTerminalTab
} from './mobile-diff-review-rpc'
import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target'
import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat'
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
'aix',
@@ -174,13 +169,12 @@ export async function resumeAiVaultSessionInTerminal(
worktreeId: string,
launch: MobileAiVaultResumeLaunch & { clientMutationId?: string }
): Promise<MobileReviewTerminalTab> {
const authority = await assertMobileTerminalAttributionDisableSupported(client)
const created = await client.sendRequest(
'session.tabs.createTerminal',
{
worktree: `id:${worktreeId}`,
env: withLegacyTerminalAttributionDisabledEnv(launch.env),
envToDelete: addLegacyTerminalAttributionDisableRequest(launch.envToDelete),
...(launch.env ? { env: launch.env } : {}),
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
@@ -188,11 +182,7 @@ export async function resumeAiVaultSessionInTerminal(
select: true,
navigation: 'caller'
},
{
timeoutMs: RESUME_RPC_TIMEOUT_MS,
budgetSpansConnect: true,
expectedRuntimeId: authority.runtimeId
}
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
@@ -53,14 +53,6 @@ describe('prepareMobileAiVaultSessionResume', () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({ ok: false, error })
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime-1',
appVersion: '1.4.90',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({
ok: true,
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
@@ -72,12 +64,9 @@ describe('prepareMobileAiVaultSessionResume', () => {
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
expect(prepared).toBe(legacy)
expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
})
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
expect(sendRequest).toHaveBeenNthCalledWith(
4,
3,
'terminal.send',
{
terminal: 'pty-1',
+1 -1
View File
@@ -9,7 +9,7 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import {
normalizeGitHubPRForBranchOutcome,
type GitHubPRForBranchResponse
} from '../../../src/shared/github-pr-for-branch-outcome'
} from '../../../src/shared/github/pull-request-for-branch-outcome'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create'
@@ -1,81 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE } from '../../../src/shared/legacy-terminal-attribution-env'
import { assertMobileTerminalAttributionDisableSupported } from './mobile-terminal-attribution-compat'
function status(result: unknown) {
return {
id: 'status',
ok: true as const,
result: { runtimeId: 'runtime', ...(result as object) },
_meta: { runtimeId: 'runtime' }
}
}
describe('mobile terminal attribution compatibility', () => {
it('refuses hosts before terminal creation environment forwarding', async () => {
const sendRequest = vi.fn().mockResolvedValue(
status({
capabilities: [
'runtime.status.compat.v1',
'runtime.environments.v1',
'mobile.tasks.v1',
'workspace-run-context.v1'
]
})
)
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
expect(sendRequest).toHaveBeenCalledWith('status.get', undefined, {
timeoutMs: 30_000,
budgetSpansConnect: true
})
})
it('rejects historical hosts without explicit removal capability', async () => {
const sendRequest = vi.fn().mockResolvedValue(status({ appVersion: '1.4.90' }))
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
})
it('accepts capability-proven current development hosts', async () => {
const sendRequest = vi
.fn()
.mockResolvedValue(
status({ appVersion: '0.0.0-dev', capabilities: ['terminal.attribution-removed.v1'] })
)
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).resolves.toEqual(
{ runtimeId: 'runtime' }
)
})
it('fails closed when host status cannot be verified', async () => {
const sendRequest = vi.fn().mockResolvedValue({
id: 'status',
ok: false,
error: { code: 'offline', message: 'Host is offline' },
_meta: { runtimeId: 'runtime' }
})
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
'Host is offline'
)
})
it('fails closed with an actionable error for malformed host status', async () => {
const sendRequest = vi.fn().mockResolvedValue(
status({
appVersion: 1.49,
capabilities: 'terminal.attribution-removed.v1'
})
)
await expect(assertMobileTerminalAttributionDisableSupported({ sendRequest })).rejects.toThrow(
'Update the host and try again'
)
})
})
@@ -1,34 +0,0 @@
import {
hostSupportsSessionTabTerminalCreateAttributionDisable,
MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
} from '../../../src/shared/legacy-terminal-attribution-env'
import type { RpcClient } from '../transport/rpc-client'
export const MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS = 30_000
export const MOBILE_TERMINAL_CREATE_RPC_OPTIONS = {
timeoutMs: MOBILE_TERMINAL_CREATE_RPC_TIMEOUT_MS,
budgetSpansConnect: true
} as const
export type MobileTerminalAttributionAuthority = Readonly<{ runtimeId: string }>
export async function assertMobileTerminalAttributionDisableSupported(
client: Pick<RpcClient, 'sendRequest'>
): Promise<MobileTerminalAttributionAuthority> {
const response = await client.sendRequest(
'status.get',
undefined,
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
)
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to verify the workspace host version.')
}
if (!hostSupportsSessionTabTerminalCreateAttributionDisable(response.result)) {
throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE)
}
const runtimeId = Reflect.get(response.result as object, 'runtimeId')
if (typeof runtimeId !== 'string' || !runtimeId) {
throw new Error(MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE)
}
return Object.freeze({ runtimeId })
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types'
import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key'
import { githubRepoIdentityKey } from '../../../src/shared/github/repository-identity-key'
import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state'
import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence'
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
+13 -41
View File
@@ -12,10 +12,6 @@ function failure(message: string): RpcResponse {
const createdTerminal = success({ tab: { type: 'terminal', id: 't1', terminal: 'term-1' } })
const sendAccepted = success({ send: { accepted: true } })
const safeHost = success({
runtimeId: 'r',
capabilities: ['terminal.attribution-removed.v1']
})
function clientReturning(...responses: RpcResponse[]) {
const sendRequest = vi.fn(async () => responses[sendRequest.mock.calls.length - 1])
@@ -24,27 +20,16 @@ function clientReturning(...responses: RpcResponse[]) {
describe('createTerminalAndSendPrompt', () => {
it('creates a terminal then sends the prompt with enter', async () => {
const client = clientReturning(safeHost, createdTerminal, sendAccepted)
const client = clientReturning(createdTerminal, sendAccepted)
await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing')
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
timeoutMs: 30_000,
budgetSpansConnect: true
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', {
worktree: 'id:wt-1',
activate: false,
select: true,
navigation: 'caller'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(
2,
'session.tabs.createTerminal',
{
worktree: 'id:wt-1',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
activate: false,
select: true,
navigation: 'caller'
},
{ timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'r' }
)
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'terminal.send', {
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', {
terminal: 'term-1',
text: 'do the thing',
enter: true
@@ -52,41 +37,28 @@ describe('createTerminalAndSendPrompt', () => {
})
it('throws and skips terminal.send when createTerminal fails', async () => {
const client = clientReturning(safeHost, failure('boom'))
const client = clientReturning(failure('boom'))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('boom')
expect(client.sendRequest).toHaveBeenCalledTimes(2)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
})
it('throws when the created-terminal response is malformed', async () => {
const client = clientReturning(safeHost, success({ tab: { type: 'terminal' } }))
const client = clientReturning(success({ tab: { type: 'terminal' } }))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
'Created terminal response was invalid'
)
expect(client.sendRequest).toHaveBeenCalledTimes(2)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
})
it('throws when terminal.send returns a failure', async () => {
const client = clientReturning(safeHost, createdTerminal, failure('send failed'))
const client = clientReturning(createdTerminal, failure('send failed'))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('send failed')
})
it('throws when terminal input is locked', async () => {
const client = clientReturning(
safeHost,
createdTerminal,
success({ send: { accepted: false } })
)
const client = clientReturning(createdTerminal, success({ send: { accepted: false } }))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
'Terminal input is locked'
)
})
it('refuses an old host before terminal creation', async () => {
const client = clientReturning(success({ appVersion: '1.4.89' }))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
'Update the host and try again'
)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
})
})
+6 -21
View File
@@ -3,14 +3,6 @@ import {
readMobileReviewCreatedTerminal,
readMobileReviewTerminalSendAccepted
} from './mobile-diff-review-rpc'
import {
addLegacyTerminalAttributionDisableRequest,
withLegacyTerminalAttributionDisabledEnv
} from '../../../src/shared/legacy-terminal-attribution-env'
import {
assertMobileTerminalAttributionDisableSupported,
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
} from './mobile-terminal-attribution-compat'
// Pure launch path for the PR triage actions ("Fix checks with AI" / "Resolve
// conflicts with AI"). Reuses the same two RPCs the diff-review send flow uses —
@@ -23,19 +15,12 @@ export async function createTerminalAndSendPrompt(
worktreeId: string,
prompt: string
): Promise<void> {
const authority = await assertMobileTerminalAttributionDisableSupported(client)
const created = await client.sendRequest(
'session.tabs.createTerminal',
{
worktree: `id:${worktreeId}`,
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
activate: false,
select: true,
navigation: 'caller'
},
{ ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId }
)
const created = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id'
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree/id'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name'
@@ -163,74 +163,6 @@ describe('useMobileDiffReviewSendActions', () => {
expect(setSendSheet).toHaveBeenCalledWith(null)
})
it('creates an unattributed terminal before sending review notes', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
id: 'status',
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
},
_meta: { runtimeId: 'runtime' }
})
.mockResolvedValueOnce({
id: 'create',
ok: true,
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'terminal-1' } },
_meta: { runtimeId: 'runtime' }
})
.mockResolvedValueOnce(sendResponse(true))
await mount({ sendRequest } as unknown as RpcClient)
await act(async () => {
await actions?.createTerminalAndSend([COMMENT])
})
expect(sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
timeoutMs: 30_000,
budgetSpansConnect: true
})
expect(sendRequest).toHaveBeenNthCalledWith(
2,
'session.tabs.createTerminal',
{
worktree: 'id:wt-1',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
activate: false,
select: true,
navigation: 'caller'
},
{ timeoutMs: 30_000, budgetSpansConnect: true, expectedRuntimeId: 'runtime' }
)
expect(sendRequest).toHaveBeenNthCalledWith(
3,
'terminal.send',
expect.objectContaining({ terminal: 'terminal-1', enter: true })
)
})
it('refuses to create review terminals on hosts that strip the bypass', async () => {
const sendRequest = vi.fn().mockResolvedValue({
id: 'status',
ok: true,
result: { appVersion: '1.4.89' },
_meta: { runtimeId: 'runtime' }
})
await mount({ sendRequest } as unknown as RpcClient)
let error: unknown
await act(async () => {
error = await actions?.createTerminalAndSend([COMMENT]).catch((err) => err)
})
expect((error as Error).message).toContain('Update the host and try again')
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest).not.toHaveBeenCalledWith('terminal.send', expect.anything())
})
it('only heals the terminal that was marked', async () => {
const sendRequest = vi.fn().mockResolvedValue(sendResponse(true))
await mount({ sendRequest } as unknown as RpcClient)
@@ -13,14 +13,6 @@ import {
} from './mobile-diff-review-rpc'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import type { ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model'
import {
addLegacyTerminalAttributionDisableRequest,
withLegacyTerminalAttributionDisabledEnv
} from '../../../src/shared/legacy-terminal-attribution-env'
import {
assertMobileTerminalAttributionDisableSupported,
MOBILE_TERMINAL_CREATE_RPC_OPTIONS
} from './mobile-terminal-attribution-compat'
type SendActionsInput = {
client: RpcClient | null
@@ -112,19 +104,12 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
if (!client || connState !== 'connected') {
throw new Error('Waiting for desktop...')
}
const authority = await assertMobileTerminalAttributionDisableSupported(client)
const response = await client.sendRequest(
'session.tabs.createTerminal',
{
worktree: `id:${worktreeId}`,
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
activate: false,
select: true,
navigation: 'caller'
},
{ ...MOBILE_TERMINAL_CREATE_RPC_OPTIONS, expectedRuntimeId: authority.runtimeId }
)
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to create terminal')
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { GitHubProjectIdentity } from '../../../src/shared/github-project-identity'
import type { GitHubProjectIdentity } from '../../../src/shared/github/project-identity'
export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType']
export type GitHubProjectRef = GitHubProjectIdentity
@@ -1,7 +1,7 @@
import {
githubRepoIdentityKey,
isDefaultGitHubHost
} from '../../../src/shared/github-repository-identity-key'
} from '../../../src/shared/github/repository-identity-key'
export type GitHubProjectRepoMatch = {
id: string
@@ -9,7 +9,7 @@ import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitl
import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key'
import { githubRepoIdentityKey } from '../../../src/shared/github/repository-identity-key'
// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived
// best-effort from the repo's remote metadata.
+55 -16
View File
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionState } from './types'
@@ -20,7 +20,12 @@ vi.mock('./connection-revival-triggers', () => ({
subscribeConnectionRevivalTriggers: () => () => {}
}))
import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context'
import {
RpcClientProvider,
useDisconnectHostClient,
useForceReconnect,
useHostClient
} from './client-context'
import { useAllHostClients } from './use-all-host-clients'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
@@ -67,25 +72,25 @@ const HOST = {
type Harness = {
readonly hook: ReturnType<typeof useHostClient>
readonly closeHost: (hostId: string) => void
readonly disconnectHost: (hostId: string) => void
readonly unmount: () => void
}
async function renderHarness(hostId: string): Promise<Harness> {
let hook: ReturnType<typeof useHostClient> | null = null
let closeHost: ((hostId: string) => void) | null = null
let disconnectHost: ((hostId: string) => void) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
hook = useHostClient(hostId)
closeHost = useCloseHost()
disconnectHost = useDisconnectHostClient()
return null
}
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
})
if (!hook || !closeHost || !renderer) {
if (!hook || !disconnectHost || !renderer) {
throw new Error('harness did not render')
}
const mounted = renderer as ReactTestRenderer
@@ -96,11 +101,11 @@ async function renderHarness(hostId: string): Promise<Harness> {
}
return hook
},
closeHost: (id) => {
if (!closeHost) {
throw new Error('closeHost not rendered')
disconnectHost: (id) => {
if (!disconnectHost) {
throw new Error('disconnectHost not rendered')
}
closeHost(id)
disconnectHost(id)
},
unmount: () => mounted.unmount()
}
@@ -203,11 +208,11 @@ describe('useHostClient', () => {
expect(harness.hook.client).not.toBeNull()
expect(harness.hook.state).toBe('connected')
// Regression (STA-1511): closeHost deletes the entry; before the fix the
// Regression (STA-1511): disconnect deletes the entry; before the fix the
// hook kept handing out the closed client, so mounted screens kept
// driving requests that could never resolve.
await act(async () => {
harness.closeHost(HOST.id)
harness.disconnectHost(HOST.id)
})
expect(fake.closeMock).toHaveBeenCalled()
expect(harness.hook.client).toBeNull()
@@ -216,6 +221,40 @@ describe('useHostClient', () => {
harness.unmount()
})
it('closes an ownerless reconnect after disconnect retires a mounted owner', async () => {
const initialClient = makeFakeClient('connected')
const replacementClient = makeFakeClient('connected')
connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(replacementClient)
loadHostsMock.mockResolvedValue([HOST])
let disconnectHost: ((hostId: string) => void) | null = null
let reconnectHost: ((hostId: string) => Promise<void>) | null = null
function Probe(): null {
useAllHostClients([HOST.id], { closeUnusedOnRelease: true })
disconnectHost = useDisconnectHostClient()
reconnectHost = useForceReconnect()
return null
}
function App({ visible }: { visible: boolean }) {
return createElement(RpcClientProvider, null, visible ? createElement(Probe) : null)
}
let renderer: { update(element: ReactElement): void; unmount(): void } | null = null
await act(async () => {
renderer = create(createElement(App, { visible: true }))
await Promise.resolve()
})
await act(async () => {
disconnectHost?.(HOST.id)
await reconnectHost?.(HOST.id)
})
act(() => renderer?.update(createElement(App, { visible: false })))
expect(initialClient.closeMock).toHaveBeenCalledOnce()
expect(replacementClient.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.unmount())
})
it('reports disconnected instead of hanging when the host id is unknown', async () => {
loadHostsMock.mockResolvedValue([])
@@ -299,10 +338,10 @@ describe('useHostClient', () => {
connectMock.mockReturnValue(fake)
loadHostsMock.mockReturnValue(hostLookup)
let closeHost: ((hostId: string) => void) | null = null
let disconnectHost: ((hostId: string) => void) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
closeHost = useCloseHost()
disconnectHost = useDisconnectHostClient()
useHostClient(HOST.id)
return null
}
@@ -311,11 +350,11 @@ describe('useHostClient', () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
})
expect(loadHostsMock).toHaveBeenCalledOnce()
if (!closeHost || !resolveHosts || !renderer) {
if (!disconnectHost || !resolveHosts || !renderer) {
throw new Error('pending-open harness did not initialize')
}
act(() => closeHost?.(HOST.id))
act(() => disconnectHost?.(HOST.id))
await act(async () => {
resolveHosts?.([HOST])
await hostLookup
+155 -169
View File
@@ -11,12 +11,16 @@ import {
type ReactNode
} from 'react'
import type { RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import { decrementPendingAcquisition } from './host-client-acquisition-count'
import {
clientActivePath,
HostClientAcquisitionRegistry,
type HostClientAcquisition
} from './host-client-acquisition-registry'
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
import {
createHostClientSelectors,
listHostClients,
notifyAllHostListeners,
notifyHostStateListeners,
@@ -25,38 +29,12 @@ import {
subscribeHostStateListener,
type CloseEntryOptions
} from './host-client-context-state'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number
unsubState: () => void
}
export type RpcClientContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
releaseAndCloseIfUnused: (hostId: string) => void
closeIfUnused: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
// null = host has no client entry and no open in flight; callers pick the default.
getKnownState: (hostId: string) => ConnectionState | null
getReconnectAttempt: (hostId: string) => number
// Why: ms-epoch of the last 'connected' (null if never this session); UI escalates "Reconnecting…" into a re-pair prompt.
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
// Why: lets the home screen feed already-loaded HostProfiles so we don't pay loadHosts() latency twice.
primeHosts: (hosts: HostProfile[]) => void
}
type StoreEntry = HostClientStoreEntry
export type { HostClientAcquisition } from './host-client-acquisition-registry'
export type { RpcClientContextValue } from './rpc-client-context-contract'
const Ctx = createContext<RpcClientContextValue | null>(null)
@@ -69,6 +47,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
// Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup.
const pendingOpensRef = useRef(new HostClientOpenRegistry())
const pendingAcquisitionsRef = useRef<Map<string, number>>(new Map())
const acquisitionsRef = useRef(new HostClientAcquisitionRegistry())
const manualDemandRef = useRef<Set<string>>(new Set())
const retrySchedulerRef = useRef<HostOpenRetryScheduler | null>(null)
// Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
@@ -79,13 +60,18 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => {
const entry = storeRef.current.get(hostId)
const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
const acquisitionCount = acquisitionsRef.current.count(hostId)
pendingOpensRef.current.cancel(hostId)
retrySchedulerRef.current?.cancel(hostId)
if (options.preserveAcquisitions && acquisitionCount > 0) {
pendingAcquisitionsRef.current.set(hostId, acquisitionCount)
} else {
acquisitionsRef.current.clear(hostId)
pendingAcquisitionsRef.current.delete(hostId)
}
if (!options.preserveAcquisitions) {
manualDemandRef.current.delete(hostId)
}
if (options.forgetPrimedHost) {
primedHostsRef.current.delete(hostId)
}
@@ -96,107 +82,59 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
notifyAllHosts()
}, [])
const closeHost = useCallback(
(hostId: string) => {
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true })
const openEntry = useCallback(
(hostId: string, allowUnowned = false): Promise<StoreEntry | null> => {
const retryScheduler = retrySchedulerRef.current
if (!retryScheduler) {
throw new Error('host retry scheduler not initialized')
}
return openHostClientEntry(
{
store: storeRef.current,
pendingOpens: pendingOpensRef.current,
pendingAcquisitions: pendingAcquisitionsRef.current,
primedHosts: primedHostsRef.current,
retryScheduler,
notifyHostState,
notifyAllHosts
},
hostId,
allowUnowned
).then((entry) => {
if (entry) {
manualDemandRef.current.delete(hostId)
}
return entry
})
},
[closeEntry]
[]
)
const openEntry = useCallback(async (hostId: string): Promise<StoreEntry | null> => {
const existing = pendingOpensRef.current.getActivePromise(hostId)
if (existing) {
await existing
return storeRef.current.get(hostId) ?? null
}
let resolve: () => void = () => {}
const promise = new Promise<void>((res) => {
resolve = res
if (!retrySchedulerRef.current) {
retrySchedulerRef.current = new HostOpenRetryScheduler({
canRetry: (hostId, generation) =>
pendingOpensRef.current.isGenerationCurrent(hostId, generation) &&
((pendingAcquisitionsRef.current.get(hostId) ?? 0) > 0 ||
manualDemandRef.current.has(hostId)) &&
!storeRef.current.has(hostId) &&
pendingOpensRef.current.getActivePromise(hostId) === null,
open: (hostId) => void openEntry(hostId, manualDemandRef.current.has(hostId))
})
const pendingOpen = pendingOpensRef.current.register(hostId, promise)
// Why: already-mounted subscribers must go amber for the Keychain read too.
notifyHostState(hostId, 'connecting')
try {
// Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start.
let host = primedHostsRef.current.get(hostId)
if (!host) {
try {
const hosts = await loadHosts()
host = hosts.find((h) => h.id === hostId)
} catch {
// Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
if (!host) {
// Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
}
if (pendingOpen.cancelled) {
return null
}
// Re-check after any await — another acquire() may have completed.
const after = storeRef.current.get(hostId)
if (after) {
return after
}
let client: RpcClient
try {
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
// Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
const unsubState = client.onStateChange((state) => {
const cur = storeRef.current.get(hostId)
if (!cur) {
return
}
cur.state = state
notifyHostState(hostId, state)
})
const entry: StoreEntry = {
client,
state: client.getState(),
refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0,
unsubState
}
pendingAcquisitionsRef.current.delete(hostId)
storeRef.current.set(hostId, entry)
notifyHostState(hostId, entry.state)
notifyAllHosts()
return entry
} finally {
pendingOpensRef.current.deleteIfCurrent(hostId, pendingOpen)
resolve()
}
}, [])
}
// Synchronous get-or-open: returns an existing client immediately, else kicks off an async open and returns null this tick.
const acquire = useCallback(
(hostId: string, host?: HostProfile): RpcClient | null => {
(hostId: string, acquisition: HostClientAcquisition, host?: HostProfile): RpcClient | null => {
if (host) {
primedHostsRef.current.set(hostId, host)
}
const acquisitionCount = acquisitionsRef.current.acquire(hostId, acquisition)
const existing = storeRef.current.get(hostId)
if (existing) {
existing.refCount += 1
existing.refCount = acquisitionCount
return existing.client
}
pendingAcquisitionsRef.current.set(
hostId,
(pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1
)
pendingAcquisitionsRef.current.set(hostId, acquisitionCount)
// Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes.
void openEntry(hostId)
return null
@@ -209,27 +147,72 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
[]
)
const refreshHostClient = useCallback(
(hostId: string) => {
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true })
if ((pendingAcquisitionsRef.current.get(hostId) ?? 0) > 0) {
void openEntry(hostId)
}
},
[closeEntry, openEntry]
)
const forgetHostClient = useCallback(
(hostId: string) => {
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false })
},
[closeEntry]
)
const disconnectHostClient = useCallback(
(hostId: string) => {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
},
[closeEntry]
)
// Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded.
const release = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (entry) {
entry.refCount = Math.max(0, entry.refCount - 1)
const release = useCallback((hostId: string, acquisition: HostClientAcquisition) => {
const acquisitionCount = acquisitionsRef.current.release(hostId, acquisition)
if (acquisitionCount === null) {
return
}
decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId)
const entry = storeRef.current.get(hostId)
if (entry) {
entry.refCount = acquisitionCount
return
}
if (acquisitionCount > 0) {
pendingAcquisitionsRef.current.set(hostId, acquisitionCount)
} else {
pendingAcquisitionsRef.current.delete(hostId)
if (!manualDemandRef.current.has(hostId)) {
pendingOpensRef.current.cancel(hostId)
retrySchedulerRef.current?.cancel(hostId)
}
}
}, [])
const releaseAndCloseIfUnused = useCallback(
(hostId: string) => {
(hostId: string, acquisition: HostClientAcquisition) => {
const acquisitionCount = acquisitionsRef.current.release(hostId, acquisition)
if (acquisitionCount === null) {
if (acquisitionsRef.current.count(hostId) === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
return
}
const entry = storeRef.current.get(hostId)
if (entry) {
entry.refCount = Math.max(0, entry.refCount - 1)
entry.refCount = acquisitionCount
if (entry.refCount === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
return
}
if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) {
if (acquisitionCount > 0) {
pendingAcquisitionsRef.current.set(hostId, acquisitionCount)
} else {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
},
@@ -239,12 +222,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const closeIfUnused = useCallback(
(hostId: string) => {
const entry = storeRef.current.get(hostId)
const pendingCount = pendingAcquisitionsRef.current.get(hostId)
const acquisitionCount = acquisitionsRef.current.count(hostId)
const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null
if (!entry && pendingCount === undefined && !hasPendingOpen) {
if (!entry && acquisitionCount === 0 && !hasPendingOpen) {
return
}
if ((entry?.refCount ?? pendingCount ?? 0) === 0) {
if (acquisitionCount === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
},
@@ -255,12 +238,15 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
// Why: ownership survives explicit close/re-pair while observers never become synthetic owners.
const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
const savedRefCount = acquisitionsRef.current.count(hostId)
manualDemandRef.current.add(hostId)
if (entry) {
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
}
pendingOpensRef.current.cancel(hostId)
retrySchedulerRef.current?.cancel(hostId)
if (savedRefCount > 0) {
pendingAcquisitionsRef.current.set(
hostId,
@@ -269,38 +255,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
// Why: Retry must read amber for the whole reopen, not grey-then-amber.
notifyHostState(hostId, 'connecting')
await openEntry(hostId)
await openEntry(hostId, true)
},
[openEntry]
)
// null = no entry and no open in flight; callers pick their own default.
const getKnownState = useCallback((hostId: string): ConnectionState | null => {
const entry = storeRef.current.get(hostId)
if (entry) {
return entry.state
}
// Why: the async open (a Keychain pass) predates the store entry; reading that
// window as 'disconnected' made every host screen flash dead on mount (S2).
return pendingOpensRef.current.getActivePromise(hostId) ? 'connecting' : null
}, [])
const getState = useCallback(
(hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected',
[getKnownState]
)
const getReconnectAttempt = useCallback((hostId: string): number => {
return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0
}, [])
const getLastConnectedAt = useCallback((hostId: string): number | null => {
return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null
}, [])
const getActivePath = useCallback((hostId: string): MobileConnectionPath => {
return clientActivePath(storeRef.current.get(hostId)?.client)
}, [])
const { getKnownState, getState, getReconnectAttempt, getLastConnectedAt, getActivePath } =
useMemo(() => createHostClientSelectors(storeRef.current, pendingOpensRef.current), [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) =>
@@ -321,6 +282,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const store = storeRef.current
return () => {
pendingOpensRef.current.cancelAll()
retrySchedulerRef.current?.cancelAll()
acquisitionsRef.current.clearAll()
manualDemandRef.current.clear()
pendingAcquisitionsRef.current.clear()
for (const [hostId] of store) {
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false })
@@ -331,8 +295,15 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: nudge live clients when the OS signals the link may be back so sessions recover without a restart (issue #5049).
useEffect(() => {
return subscribeConnectionRevivalTriggers((reason) => {
for (const hostId of pendingAcquisitionsRef.current.keys()) {
retrySchedulerRef.current?.expedite(hostId)
}
for (const entry of storeRef.current.values()) {
entry.client.notifyForeground(reason)
try {
entry.client.notifyForeground(reason)
} catch {
// One broken physical session must not block recovery for other hosts.
}
}
})
}, [])
@@ -344,7 +315,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
releaseAndCloseIfUnused,
closeIfUnused,
forceReconnect,
closeHost,
refreshHostClient,
forgetHostClient,
disconnectHostClient,
getState,
getKnownState,
getReconnectAttempt,
@@ -361,7 +334,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
releaseAndCloseIfUnused,
closeIfUnused,
forceReconnect,
closeHost,
refreshHostClient,
forgetHostClient,
disconnectHostClient,
getState,
getKnownState,
getReconnectAttempt,
@@ -399,6 +374,7 @@ export function useHostClient(hostId: string | undefined): {
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(hostId)
const acquisitionRef = useRef<HostClientAcquisition>({})
useEffect(() => {
if (!hostId) {
@@ -421,12 +397,12 @@ export function useHostClient(hostId: string | undefined): {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
// Why: closeHost deletes the entry with no replacement; null it so screens don't drive a dead client (STA-1511).
// Why: disconnect/forget deletes the entry; never retain a dead client (STA-1511).
clientRef.current = null
force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId)
const initial = ctx.acquire(hostId, acquisitionRef.current)
clientRef.current = initial
setState(ctx.getKnownState(hostId) ?? 'connecting')
if (initial) {
@@ -436,7 +412,7 @@ export function useHostClient(hostId: string | undefined): {
return () => {
cancelled = true
unsub()
ctx.release(hostId)
ctx.release(hostId, acquisitionRef.current)
clientRef.current = null
clientHostIdRef.current = undefined
}
@@ -453,9 +429,19 @@ export function useHostClient(hostId: string | undefined): {
}
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
export function useCloseHost(): (hostId: string) => void {
export function useRefreshHostClient(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.closeHost
return ctx.refreshHostClient
}
export function useForgetHostClient(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.forgetHostClient
}
export function useDisconnectHostClient(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.disconnectHostClient
}
// Why: future-proof "Connection issues — try again" affordance.
@@ -1,9 +0,0 @@
export function decrementPendingAcquisition(pending: Map<string, number>, hostId: string): number {
const next = Math.max(0, (pending.get(hostId) ?? 0) - 1)
if (next === 0) {
pending.delete(hostId)
} else {
pending.set(hostId, next)
}
return next
}
@@ -0,0 +1,38 @@
export type HostClientAcquisition = object
export class HostClientAcquisitionRegistry {
private readonly acquisitions = new Map<string, Set<HostClientAcquisition>>()
acquire(hostId: string, acquisition: HostClientAcquisition): number {
let active = this.acquisitions.get(hostId)
if (!active) {
active = new Set()
this.acquisitions.set(hostId, active)
}
active.add(acquisition)
return active.size
}
release(hostId: string, acquisition: HostClientAcquisition): number | null {
const active = this.acquisitions.get(hostId)
if (!active?.delete(acquisition)) {
return null
}
if (active.size === 0) {
this.acquisitions.delete(hostId)
}
return active.size
}
count(hostId: string): number {
return this.acquisitions.get(hostId)?.size ?? 0
}
clear(hostId: string): void {
this.acquisitions.delete(hostId)
}
clearAll(): void {
this.acquisitions.clear()
}
}
@@ -1,4 +1,6 @@
import type { RpcClient } from './rpc-client'
import type { HostClientOpenRegistry } from './host-client-open-registry'
import type { HostClientStoreEntry } from './host-entry-opener'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
@@ -62,6 +64,30 @@ export function primeHostProfiles(cache: Map<string, HostProfile>, hosts: HostPr
}
}
export function createHostClientSelectors(
entries: ReadonlyMap<string, HostClientStoreEntry>,
pendingOpens: HostClientOpenRegistry
) {
const getKnownState = (hostId: string): ConnectionState | null => {
const entry = entries.get(hostId)
if (entry) {
return entry.state
}
// Why: the Keychain pass predates the store entry; this window is connecting.
return pendingOpens.getActivePromise(hostId) ? 'connecting' : null
}
return {
getKnownState,
getState: (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected',
getReconnectAttempt: (hostId: string): number =>
entries.get(hostId)?.client.getReconnectAttempt() ?? 0,
getLastConnectedAt: (hostId: string): number | null =>
entries.get(hostId)?.client.getLastConnectedAt() ?? null,
getActivePath: (hostId: string): MobileConnectionPath =>
clientActivePath(entries.get(hostId)?.client)
}
}
export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
if (typeof logical?.getActivePath !== 'function') {
@@ -1,10 +1,12 @@
export type HostClientOpenTicket = {
cancelled: boolean
generation: number
promise: Promise<void>
}
export class HostClientOpenRegistry {
private readonly pending = new Map<string, HostClientOpenTicket>()
private readonly generations = new Map<string, number>()
getActivePromise(hostId: string): Promise<void> | null {
const ticket = this.pending.get(hostId)
@@ -12,7 +14,11 @@ export class HostClientOpenRegistry {
}
register(hostId: string, promise: Promise<void>): HostClientOpenTicket {
const ticket = { cancelled: false, promise }
const ticket = {
cancelled: false,
generation: this.advanceGeneration(hostId),
promise
}
this.pending.set(hostId, ticket)
return ticket
}
@@ -25,6 +31,15 @@ export class HostClientOpenRegistry {
// reference immediately while the ticket still cancels its continuation.
this.pending.delete(hostId)
}
this.advanceGeneration(hostId)
}
isCurrent(hostId: string, ticket: HostClientOpenTicket): boolean {
return !ticket.cancelled && this.isGenerationCurrent(hostId, ticket.generation)
}
isGenerationCurrent(hostId: string, generation: number): boolean {
return this.generations.get(hostId) === generation
}
deleteIfCurrent(hostId: string, ticket: HostClientOpenTicket): void {
@@ -34,9 +49,16 @@ export class HostClientOpenRegistry {
}
cancelAll(): void {
for (const ticket of this.pending.values()) {
for (const [hostId, ticket] of this.pending) {
ticket.cancelled = true
this.advanceGeneration(hostId)
}
this.pending.clear()
}
private advanceGeneration(hostId: string): number {
const generation = (this.generations.get(hostId) ?? 0) + 1
this.generations.set(hostId, generation)
return generation
}
}
+141
View File
@@ -0,0 +1,141 @@
import { connectionLogStore } from './connection-log-buffer'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { HostClientOpenRegistry } from './host-client-open-registry'
import type { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, HostProfile } from './types'
export type HostClientStoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number
unsubState: () => void
}
type HostEntryOpenerState = {
store: Map<string, HostClientStoreEntry>
pendingOpens: HostClientOpenRegistry
pendingAcquisitions: Map<string, number>
primedHosts: Map<string, HostProfile>
retryScheduler: HostOpenRetryScheduler
notifyHostState: (hostId: string, state: ConnectionState) => void
notifyAllHosts: () => void
}
type HostOpenFailureCategory = 'catalog-unavailable' | 'host-not-found' | 'client-construction'
export async function openHostClientEntry(
state: HostEntryOpenerState,
hostId: string,
allowUnowned = false
): Promise<HostClientStoreEntry | null> {
const existing = state.pendingOpens.getActivePromise(hostId)
if (existing) {
await existing
return state.store.get(hostId) ?? null
}
let resolve: () => void = () => {}
const promise = new Promise<void>((res) => {
resolve = res
})
const ticket = state.pendingOpens.register(hostId, promise)
let settled = false
const settle = () => {
if (settled) {
return
}
settled = true
state.pendingOpens.deleteIfCurrent(hostId, ticket)
resolve()
}
const isCurrent = () => state.pendingOpens.isCurrent(hostId, ticket)
const isWanted = () => allowUnowned || (state.pendingAcquisitions.get(hostId) ?? 0) > 0
const failCurrentOpen = (category: HostOpenFailureCategory) => {
if (!isCurrent()) {
return
}
settle()
state.notifyHostState(hostId, 'disconnected')
state.notifyAllHosts()
const retry = state.retryScheduler.recordFailure(hostId, ticket.generation)
connectionLogStore.append(hostId, {
id: `host-open-${ticket.generation}-${Date.now()}`,
ts: Date.now(),
level: 'error',
message: 'Host client open failed',
detail: `${category}; retry ${retry.nextDelayMs}ms (failure ${retry.failureCount})`
})
}
state.notifyHostState(hostId, 'connecting')
try {
let host = state.primedHosts.get(hostId)
if (!host) {
try {
const hosts = await loadHosts()
host = hosts.find((candidate) => candidate.id === hostId)
} catch {
failCurrentOpen('catalog-unavailable')
return null
}
if (!host) {
failCurrentOpen('host-not-found')
return null
}
}
if (!isCurrent() || !isWanted()) {
return null
}
const published = state.store.get(hostId)
if (published) {
settle()
state.retryScheduler.recordSuccess(hostId)
return published
}
let client: RpcClient
try {
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
failCurrentOpen('client-construction')
return null
}
if (!isCurrent() || !isWanted() || state.store.has(hostId)) {
client.close()
return state.store.get(hostId) ?? null
}
const unsubState = client.onStateChange((next) => {
const current = state.store.get(hostId)
if (!current) {
return
}
current.state = next
state.notifyHostState(hostId, next)
})
const entry: HostClientStoreEntry = {
client,
state: client.getState(),
refCount: state.pendingAcquisitions.get(hostId) ?? 0,
unsubState
}
state.pendingAcquisitions.delete(hostId)
state.store.set(hostId, entry)
settle()
const priorFailureCount = state.retryScheduler.recordSuccess(hostId)
if (priorFailureCount > 0) {
connectionLogStore.append(hostId, {
id: `host-open-recovered-${ticket.generation}-${Date.now()}`,
ts: Date.now(),
level: 'success',
message: 'Host client recovered',
detail: `after ${priorFailureCount} failed open${priorFailureCount === 1 ? '' : 's'}`
})
}
state.notifyHostState(hostId, entry.state)
state.notifyAllHosts()
return entry
} finally {
settle()
}
}
@@ -0,0 +1,388 @@
import { createElement, type ReactElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import type { ConnectionState } from './types'
const openHostLogicalClientMock = vi.fn()
const loadHostsMock = vi.fn()
const revival = vi.hoisted(() => ({ callback: null as null | ((reason: 'focus') => void) }))
vi.mock('./host-logical-client', () => ({
openHostLogicalClient: (...args: unknown[]) => openHostLogicalClientMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
vi.mock('./connection-revival-triggers', () => ({
subscribeConnectionRevivalTriggers: (callback: (reason: 'focus') => void) => {
revival.callback = callback
return () => {
revival.callback = null
}
}
}))
import {
useDisconnectHostClient,
RpcClientProvider,
useForgetHostClient,
useForceReconnect,
useHostClient,
useRefreshHostClient
} from './client-context'
const HOST = {
id: 'host-1',
name: 'Host 1',
endpoint: 'ws://127.0.0.1:1',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
}
type MountedRenderer = {
unmount(): void
update(element: ReactElement): void
}
function fakeClient(): RpcClient {
return {
sendRequest: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
}
beforeEach(() => {
vi.useFakeTimers()
openHostLogicalClientMock.mockReset()
loadHostsMock.mockReset()
revival.callback = null
})
afterEach(() => vi.useRealTimers())
describe('wanted host open recovery', () => {
it('recovers after a transient catalog failure without remounting', async () => {
const client = fakeClient()
loadHostsMock.mockRejectedValueOnce(new Error('catalog unavailable')).mockResolvedValue([HOST])
openHostLogicalClientMock.mockReturnValue(client)
let observed: { client: RpcClient | null; state: ConnectionState } | null = null
let renderer: MountedRenderer | null = null
function Probe(): null {
observed = useHostClient(HOST.id)
return null
}
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(observed).toMatchObject({ client: null, state: 'disconnected' })
expect(loadHostsMock).toHaveBeenCalledOnce()
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(loadHostsMock).toHaveBeenCalledTimes(2)
expect(openHostLogicalClientMock).toHaveBeenCalledOnce()
expect(observed).toMatchObject({ client, state: 'connected' })
} finally {
act(() => renderer?.unmount())
}
})
it('recovers when a missing host appears in the catalog', async () => {
const client = fakeClient()
loadHostsMock.mockResolvedValueOnce([]).mockResolvedValueOnce([HOST])
openHostLogicalClientMock.mockReturnValue(client)
let observed: { client: RpcClient | null; state: ConnectionState } | null = null
let renderer: MountedRenderer | null = null
function Probe(): null {
observed = useHostClient(HOST.id)
return null
}
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(loadHostsMock).toHaveBeenCalledTimes(2)
expect(observed).toMatchObject({ client, state: 'connected' })
} finally {
act(() => renderer?.unmount())
}
})
it('recovers after synchronous client construction fails', async () => {
const client = fakeClient()
loadHostsMock.mockResolvedValue([HOST])
openHostLogicalClientMock.mockImplementationOnce(() => {
throw new Error('bad transient profile')
})
openHostLogicalClientMock.mockReturnValueOnce(client)
let observed: { client: RpcClient | null; state: ConnectionState } | null = null
let renderer: MountedRenderer | null = null
function Probe(): null {
observed = useHostClient(HOST.id)
return null
}
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(openHostLogicalClientMock).toHaveBeenCalledTimes(2)
expect(observed).toMatchObject({ client, state: 'connected' })
} finally {
act(() => renderer?.unmount())
}
})
it('expedites recovery without resetting the failure tier', async () => {
const client = fakeClient()
loadHostsMock
.mockRejectedValueOnce(new Error('first failure'))
.mockRejectedValueOnce(new Error('second failure'))
.mockResolvedValueOnce([HOST])
openHostLogicalClientMock.mockReturnValue(client)
let renderer: MountedRenderer | null = null
function Probe(): null {
useHostClient(HOST.id)
return null
}
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
act(() => revival.callback?.('focus'))
await act(async () => {
await Promise.resolve()
})
expect(loadHostsMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1_999)
expect(loadHostsMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(loadHostsMock).toHaveBeenCalledTimes(3)
} finally {
act(() => renderer?.unmount())
}
})
it('forgets demand while a retry is waiting', async () => {
loadHostsMock.mockRejectedValue(new Error('catalog unavailable'))
let forgetHostClient: ((hostId: string) => void) | null = null
let renderer: MountedRenderer | null = null
function Probe(): null {
useHostClient(HOST.id)
forgetHostClient = useForgetHostClient()
return null
}
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
act(() => forgetHostClient?.(HOST.id))
await vi.advanceTimersByTimeAsync(120_000)
expect(loadHostsMock).toHaveBeenCalledOnce()
} finally {
act(() => renderer?.unmount())
}
})
it('does not let a retired owner cancel a newer acquisition', async () => {
let resolveReplacement: ((hosts: (typeof HOST)[]) => void) | null = null
const replacementLookup = new Promise<(typeof HOST)[]>((resolve) => {
resolveReplacement = resolve
})
loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(replacementLookup)
const firstClient = fakeClient()
const replacementClient = fakeClient()
openHostLogicalClientMock
.mockReturnValueOnce(firstClient)
.mockReturnValueOnce(replacementClient)
let disconnectHostClient: ((hostId: string) => void) | null = null
function RetiredOwner(): null {
useHostClient(HOST.id)
disconnectHostClient = useDisconnectHostClient()
return null
}
function NewOwner(): null {
useHostClient(HOST.id)
return null
}
function App({ oldVisible, newVisible }: { oldVisible: boolean; newVisible: boolean }) {
return createElement(
RpcClientProvider,
null,
oldVisible ? createElement(RetiredOwner) : null,
newVisible ? createElement(NewOwner) : null
)
}
let renderer: MountedRenderer | null = null
try {
await act(async () => {
renderer = create(createElement(App, { oldVisible: true, newVisible: false }))
await Promise.resolve()
})
act(() => disconnectHostClient?.(HOST.id))
act(() => renderer?.update(createElement(App, { oldVisible: true, newVisible: true })))
act(() => renderer?.update(createElement(App, { oldVisible: false, newVisible: true })))
await act(async () => {
resolveReplacement?.([HOST])
await replacementLookup
})
expect(openHostLogicalClientMock).toHaveBeenCalledTimes(2)
expect(replacementClient.close).not.toHaveBeenCalled()
} finally {
act(() => renderer?.unmount())
}
})
it('keeps explicit reconnect demand through a pre-client failure', async () => {
loadHostsMock
.mockRejectedValueOnce(new Error('catalog unavailable'))
.mockResolvedValueOnce([HOST])
openHostLogicalClientMock.mockReturnValue(fakeClient())
let forceReconnect: ((hostId: string) => Promise<void>) | null = null
function Probe(): null {
forceReconnect = useForceReconnect()
return null
}
let renderer: MountedRenderer | null = null
try {
act(() => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
})
await act(async () => {
await forceReconnect?.(HOST.id)
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(loadHostsMock).toHaveBeenCalledTimes(2)
expect(openHostLogicalClientMock).toHaveBeenCalledOnce()
} finally {
act(() => renderer?.unmount())
}
})
it('cancels an in-flight open after the final owner releases', async () => {
let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null
const lookup = new Promise<(typeof HOST)[]>((resolve) => {
resolveHosts = resolve
})
loadHostsMock.mockReturnValue(lookup)
openHostLogicalClientMock.mockReturnValue(fakeClient())
function Probe(): null {
useHostClient(HOST.id)
return null
}
function App({ visible }: { visible: boolean }) {
return createElement(RpcClientProvider, null, visible ? createElement(Probe) : null)
}
let renderer: MountedRenderer | null = null
try {
act(() => {
renderer = create(createElement(App, { visible: true }))
})
expect(loadHostsMock).toHaveBeenCalledOnce()
act(() => renderer?.update(createElement(App, { visible: false })))
await act(async () => {
resolveHosts?.([HOST])
await lookup
})
expect(openHostLogicalClientMock).not.toHaveBeenCalled()
} finally {
act(() => renderer?.unmount())
}
})
it('ignores a canceled failure after a replacement client succeeds', async () => {
let rejectStale: ((error: Error) => void) | null = null
const staleLookup = new Promise<(typeof HOST)[]>((_, reject) => {
rejectStale = reject
})
loadHostsMock.mockReturnValueOnce(staleLookup).mockResolvedValueOnce([HOST])
const client = fakeClient()
openHostLogicalClientMock.mockReturnValue(client)
let refreshHostClient: ((hostId: string) => void) | null = null
let replacement: { client: RpcClient | null; state: ConnectionState } | null = null
function Primary(): null {
useHostClient(HOST.id)
refreshHostClient = useRefreshHostClient()
return null
}
function Replacement(): null {
replacement = useHostClient(HOST.id)
return null
}
function App({ showReplacement }: { showReplacement: boolean }) {
return createElement(
RpcClientProvider,
null,
createElement(Primary),
showReplacement ? createElement(Replacement) : null
)
}
let renderer: MountedRenderer | null = null
try {
act(() => {
renderer = create(createElement(App, { showReplacement: false }))
})
act(() => refreshHostClient?.(HOST.id))
await act(async () => {
renderer?.update(createElement(App, { showReplacement: true }))
await Promise.resolve()
})
expect(replacement).toMatchObject({ client, state: 'connected' })
await act(async () => {
rejectStale?.(new Error('stale catalog failure'))
await Promise.resolve()
})
expect(replacement).toMatchObject({ client, state: 'connected' })
} finally {
act(() => renderer?.unmount())
}
})
})
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
describe('HostOpenRetryScheduler', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('advances through bounded retry tiers', async () => {
let generation = 1
const open = vi.fn()
const scheduler = new HostOpenRetryScheduler({
canRetry: (_hostId, candidate) => candidate === generation,
open
})
scheduler.recordFailure('host-1', generation)
await vi.advanceTimersByTimeAsync(1_000)
expect(open).toHaveBeenCalledOnce()
generation = 2
scheduler.recordFailure('host-1', generation)
await vi.advanceTimersByTimeAsync(1_999)
expect(open).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(open).toHaveBeenCalledTimes(2)
})
it('expedites without forgiving the failure streak', async () => {
let generation = 1
const open = vi.fn()
const scheduler = new HostOpenRetryScheduler({
canRetry: (_hostId, candidate) => candidate === generation,
open
})
scheduler.recordFailure('host-1', generation)
scheduler.expedite('host-1')
expect(open).toHaveBeenCalledOnce()
generation = 2
scheduler.recordFailure('host-1', generation)
await vi.advanceTimersByTimeAsync(1_999)
expect(open).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(open).toHaveBeenCalledTimes(2)
})
it('cancels retry delivery', async () => {
const open = vi.fn()
const scheduler = new HostOpenRetryScheduler({ canRetry: () => true, open })
scheduler.recordFailure('host-1', 1)
scheduler.cancel('host-1')
await vi.advanceTimersByTimeAsync(60_000)
expect(open).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,79 @@
const RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 15_000, 30_000, 60_000] as const
type RetryState = {
failureCount: number
generation: number
timer: ReturnType<typeof setTimeout> | null
}
type HostOpenRetrySchedulerOptions = {
canRetry: (hostId: string, generation: number) => boolean
open: (hostId: string) => void
setTimer?: typeof setTimeout
clearTimer?: typeof clearTimeout
}
export class HostOpenRetryScheduler {
private readonly states = new Map<string, RetryState>()
private readonly setTimer: typeof setTimeout
private readonly clearTimer: typeof clearTimeout
constructor(private readonly options: HostOpenRetrySchedulerOptions) {
this.setTimer = options.setTimer ?? setTimeout
this.clearTimer = options.clearTimer ?? clearTimeout
}
recordFailure(hostId: string, generation: number): { failureCount: number; nextDelayMs: number } {
const previous = this.states.get(hostId)
this.clearStateTimer(previous)
const failureCount = (previous?.failureCount ?? 0) + 1
const state: RetryState = { failureCount, generation, timer: null }
this.states.set(hostId, state)
const delayIndex = Math.min(failureCount - 1, RETRY_DELAYS_MS.length - 1)
const nextDelayMs = RETRY_DELAYS_MS[delayIndex]
if (!this.options.canRetry(hostId, generation)) {
return { failureCount, nextDelayMs }
}
state.timer = this.setTimer(() => {
state.timer = null
if (this.states.get(hostId) === state && this.options.canRetry(hostId, generation)) {
this.options.open(hostId)
}
}, nextDelayMs)
return { failureCount, nextDelayMs }
}
expedite(hostId: string): void {
const state = this.states.get(hostId)
if (!state || !this.options.canRetry(hostId, state.generation)) {
return
}
this.clearStateTimer(state)
this.options.open(hostId)
}
recordSuccess(hostId: string): number {
const priorFailureCount = this.states.get(hostId)?.failureCount ?? 0
this.cancel(hostId)
return priorFailureCount
}
cancel(hostId: string): void {
this.clearStateTimer(this.states.get(hostId))
this.states.delete(hostId)
}
cancelAll(): void {
for (const state of this.states.values()) {
this.clearStateTimer(state)
}
this.states.clear()
}
private clearStateTimer(state: RetryState | undefined): void {
if (state?.timer !== null && state?.timer !== undefined) {
this.clearTimer(state.timer)
state.timer = null
}
}
}
@@ -6,12 +6,12 @@ import { removeHost } from './host-store'
export async function removeHostAndCloseClient(
hostId: string,
closeHostClient: (hostId: string) => void
forgetHostClient: (hostId: string) => void
): Promise<void> {
// Why: closing before the metadata commit can strand a still-paired host on
// storage failure; closing immediately after success prevents socket leaks.
await removeHost(hostId)
closeHostClient(hostId)
forgetHostClient(hostId)
// Why: the notification session outlives the socket by design (it must survive
// reconnects), so removal is the only thing that can retire it. Left behind, a
// re-pair of the same host would inherit a watermark for a counter it never saw.
@@ -19,12 +19,10 @@ function routerFixture() {
const router = new MobileEndpointNudgeRouter({
logical,
controller: { handleActiveNudge } as unknown as RelayReconnectController,
now: () => 20_000,
isStopped: () => false,
isForeground: () => foreground,
setForeground,
replaceRelay: vi.fn(),
recoverAfterDeadProbe: vi.fn(),
scheduleDirectProbe
})
return { handleActiveNudge, logical, router, scheduleDirectProbe, setForeground }
@@ -38,9 +36,7 @@ describe('MobileEndpointNudgeRouter', () => {
expect(fixture.setForeground).toHaveBeenCalledWith(true)
expect(fixture.handleActiveNudge).toHaveBeenCalledWith(fixture.logical, 'focus')
expect(fixture.logical.sendRequest).toHaveBeenCalledWith('status.get', null, {
timeoutMs: 4000
})
expect(fixture.logical.sendRequest).not.toHaveBeenCalled()
expect(fixture.scheduleDirectProbe).toHaveBeenCalledOnce()
})
@@ -1,4 +1,3 @@
import { MobileRelayFocusProbe } from './mobile-relay-focus-probe'
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ForegroundNudgeReason } from './types'
@@ -6,28 +5,17 @@ import type { ForegroundNudgeReason } from './types'
// Routes attention/network nudges: focus and app-resume probe a healthy relay,
// a network change replaces it make-before-break, everything else re-enters recovery.
export class MobileEndpointNudgeRouter {
private readonly focusProbe: MobileRelayFocusProbe
constructor(
private readonly args: {
logical: StableLogicalRpcClient
controller: RelayReconnectController
now: () => number
isStopped: () => boolean
isForeground: () => boolean
setForeground: (foreground: boolean) => void
replaceRelay: () => void
recoverAfterDeadProbe: (detail: string) => void
scheduleDirectProbe: () => void
}
) {
this.focusProbe = new MobileRelayFocusProbe({
logical: args.logical,
now: args.now,
canProbe: () => !args.isStopped() && args.isForeground(),
onDead: args.recoverAfterDeadProbe
})
}
) {}
nudge(reason: ForegroundNudgeReason): void {
const { args } = this
@@ -43,9 +31,7 @@ export class MobileEndpointNudgeRouter {
args.setForeground(true)
}
const verdict = args.controller.handleActiveNudge(args.logical, reason)
if (verdict === 'probe') {
void this.focusProbe.probe()
} else if (verdict === 'replace') {
if (verdict === 'replace') {
args.replaceRelay()
}
args.scheduleDirectProbe()
@@ -89,7 +89,7 @@ describe('mobile endpoint supervisor nudges', () => {
supervisor.stop()
})
it('probes a healthy relay on a focus nudge and keeps it untouched', async () => {
it('leaves physical relay probing to the session watchdog', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -97,28 +97,27 @@ describe('mobile endpoint supervisor nudges', () => {
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.sendRequest).toHaveBeenCalledWith('status.get', null, { timeoutMs: 4000 })
expect(logical.sendRequest).not.toHaveBeenCalled()
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(deps.openRelay).not.toHaveBeenCalled()
// Repeated focus events inside the probe window coalesce into one probe.
// Repeated focus events never create a second supervisor-owned liveness policy.
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.sendRequest).toHaveBeenCalledOnce()
expect(logical.sendRequest).not.toHaveBeenCalled()
supervisor.stop()
})
it('suspends and re-dials when the focus probe fails', async () => {
it('does not suspend a relay from one focus RPC failure', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.sendRequest.mockRejectedValueOnce(new Error('relay RPC timed out: status.get'))
supervisor.nudge('focus')
await vi.advanceTimersByTimeAsync(0)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
await vi.waitFor(() => expect(deps.openRelay).toHaveBeenCalledOnce())
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
@@ -60,16 +60,10 @@ export class MobileEndpointSupervisor {
this.nudgeRouter = new MobileEndpointNudgeRouter({
logical,
controller: this.relayReconnect,
now: dependencies.now,
isStopped: () => this.stopped,
isForeground: () => this.foreground,
setForeground: (foreground) => this.setForeground(foreground),
replaceRelay: () => void this.recoverRelay(true, true),
recoverAfterDeadProbe: (detail) => {
this.logRelay('relay probe failed; recovering', detail)
suspendRelayIfStillConnected(this.relayReconnect, this.logical)
void this.recoverRelay()
},
scheduleDirectProbe: () => this.directProbe.schedule(0)
})
this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => {
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./mobile-e2ee-v2-client-session', () => ({
MobileE2EEV2ClientSession: {
create: () => ({})
}
}))
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEV2PhysicalChannel: class {
start = vi.fn()
handleMessage = vi.fn(async () => {})
sendText = vi.fn(() => true)
sendBinary = vi.fn(() => true)
dispose = vi.fn()
}
}))
import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link'
class ThrowingSocket {
static readonly OPEN = 1
readonly OPEN = ThrowingSocket.OPEN
readyState = ThrowingSocket.OPEN
bufferedAmount = 0
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
send = vi.fn(() => {
throw new Error('relay auth write failed')
})
close = vi.fn()
}
describe('MobileRelayE2eeLink', () => {
it('routes the initial relay-auth write exception through link failure', () => {
const socket = new ThrowingSocket()
const onError = vi.fn()
new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
expect(() => socket.onopen?.()).not.toThrow()
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'relay auth write failed' })
)
expect(socket.close).toHaveBeenCalledOnce()
})
})
+12 -8
View File
@@ -76,14 +76,18 @@ export class MobileRelayE2eeLink {
private bindSocket(): void {
this.socket.onopen = () => {
this.socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: this.options.credential
})
)
try {
this.socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: this.options.credential
})
)
} catch (error) {
this.fail(asError(error))
}
}
this.socket.onmessage = (event) => {
this.inboundChain = this.inboundChain
@@ -1,53 +0,0 @@
import {
isLogicalClientCutoverError,
type StableLogicalRpcClient
} from './stable-logical-rpc-client'
// Why: a probe must fail well before the 12s connect ceiling so a dead socket
// still recovers in a few seconds, but outlast one slow cellular RTT.
const PROBE_TIMEOUT_MS = 4_000
// Why: focus fires per navigation event; one liveness answer covers them all.
const PROBE_MIN_INTERVAL_MS = 10_000
// Cheap liveness check for a focus nudge on an active relay: a healthy session
// answers and nothing visible changes; only a dead one is handed to onDead.
export class MobileRelayFocusProbe {
private lastProbeAt = 0
constructor(
private readonly args: {
logical: StableLogicalRpcClient
now: () => number
canProbe: () => boolean
onDead: (detail: string) => void
}
) {}
async probe(): Promise<void> {
const { logical, now } = this.args
const at = now()
if (
!this.args.canProbe() ||
at - this.lastProbeAt < PROBE_MIN_INTERVAL_MS ||
logical.getActivePath() !== 'relay' ||
logical.getState() !== 'connected'
) {
return
}
this.lastProbeAt = at
const generation = logical.getGeneration()
try {
await logical.sendRequest('status.get', null, { timeoutMs: PROBE_TIMEOUT_MS })
} catch (error) {
if (
isLogicalClientCutoverError(error) ||
!this.args.canProbe() ||
logical.getGeneration() !== generation ||
logical.getActivePath() !== 'relay'
) {
return
}
this.args.onDead(String((error as Error).message).slice(0, 80))
}
}
}
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
linkOptions: null as null | {
onHello(value: unknown): void
onAuthenticated(): void
onText(value: string): void
onBinary(value: Uint8Array): void
},
sendText: vi.fn(() => true),
close: vi.fn()
}))
vi.mock('./mobile-relay-e2ee-link', () => ({
MobileRelayE2eeLink: class {
constructor(options: NonNullable<typeof fakes.linkOptions>) {
fakes.linkOptions = options
}
sendText = fakes.sendText
close = fakes.close
}
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
async function authenticateSession() {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
resumeCredentialVersion: 3,
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 30_000
})
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 3,
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
fakes.linkOptions!.onAuthenticated()
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const confirmation = sentRequests()[0]!
fakes.linkOptions!.onText(
JSON.stringify({
id: confirmation.id,
ok: true,
result: {
v: 1,
relay,
resumeConfirmation: {
v: 1,
reqId: 'confirm-1',
currentVersion: 3,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: Date.now() + 300_000
}
},
_meta: { runtimeId: 'runtime-1' }
})
)
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
fakes.sendText.mockClear()
return session
}
function sentRequests(): Array<{ id: string; method: string }> {
return fakes.sendText.mock.calls.map(
([value]) => JSON.parse(value as string) as { id: string; method: string }
)
}
describe('mobile relay RPC session liveness', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
fakes.linkOptions = null
fakes.sendText.mockReturnValue(true)
})
afterEach(() => vi.useRealTimers())
it('sends no periodic traffic while an authenticated relay is idle', async () => {
const session = await authenticateSession()
await vi.advanceTimersByTimeAsync(60_000)
expect(fakes.sendText).not.toHaveBeenCalled()
expect(session.getState()).toBe('connected')
session.close()
})
it('disconnects after two fair foreground misses', async () => {
const session = await authenticateSession()
session.notifyForeground('focus')
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
await vi.advanceTimersByTimeAsync(4_000)
expect(session.getState()).toBe('connected')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(4_000)
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
})
it('rate-limits foreground sequences without suppressing a retry', async () => {
const session = await authenticateSession()
session.notifyForeground('focus')
const firstProbe = sentRequests()[0]!
fakes.linkOptions!.onText(
JSON.stringify({ id: firstProbe.id, ok: true, result: {}, _meta: { runtimeId: 'r1' } })
)
session.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(9_999)
session.notifyForeground('app-resume')
expect(fakes.sendText).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
session.notifyForeground('focus')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
session.close()
})
it('does not probe the old relay on a network change', async () => {
const session = await authenticateSession()
session.notifyForeground('network-change')
expect(fakes.sendText).not.toHaveBeenCalled()
session.close()
})
it('does not probe when work follows prolonged inbound silence', async () => {
const session = await authenticateSession()
await vi.advanceTimersByTimeAsync(60_000)
const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
const outcome = pending.catch(() => undefined)
session.subscribe('terminal.subscribe', { terminal: 'term' }, vi.fn())
await vi.advanceTimersByTimeAsync(0)
expect(sentRequests().map(({ method }) => method)).toEqual([
'terminal.send',
'terminal.subscribe'
])
session.close()
await outcome
})
it('fails immediately when a liveness probe cannot be written', async () => {
const session = await authenticateSession()
fakes.sendText.mockReturnValue(false)
session.notifyForeground('focus')
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
})
})
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
BrowserScreencastOpcode,
encodeBrowserScreencastFrame
@@ -103,6 +103,7 @@ describe('mobile relay RPC session', () => {
fakes.linkOptions = null
fakes.sendText.mockReturnValue(true)
})
afterEach(() => vi.useRealTimers())
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest } = await authenticateSession()
@@ -9,9 +9,14 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import { isRpcResponse } from './rpc-response-shape'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcResponse } from './types'
const RELAY_PROBE_TIMEOUT_MS = 4_000
const RELAY_MISSED_PROBE_LIMIT = 2
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
@@ -48,6 +53,7 @@ export function connectMobileRelayRpcSession(args: {
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
let closed = false
const livenessIdentity = {}
const streams = new MobileRelayRpcStreams({
nextId,
sendFrame,
@@ -74,8 +80,14 @@ export function connectMobileRelayRpcSession(args: {
publishState('handshaking')
},
onAuthenticated: () => void confirmResume(),
onText: handleText,
onBinary: handleBinary,
onText: (plaintext) => {
livenessWatchdog.noteAuthenticatedInbound(livenessIdentity)
handleText(plaintext)
},
onBinary: (plaintext) => {
livenessWatchdog.noteAuthenticatedInbound(livenessIdentity)
handleBinary(plaintext)
},
onError: fail
})
@@ -103,12 +115,17 @@ export function connectMobileRelayRpcSession(args: {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
},
notifyForeground: () => {},
notifyForeground: (reason) => {
if (state === 'connected' && reason !== 'network-change') {
livenessWatchdog.probeNow(livenessIdentity)
}
},
close() {
if (closed) {
return
}
closed = true
livenessWatchdog.stop(livenessIdentity)
link.close()
rejectPending(new Error('Client closed'))
streams.clear()
@@ -119,6 +136,16 @@ export function connectMobileRelayRpcSession(args: {
getResumeConfirmation: () => resumeConfirmation,
getFailure: () => failure
}
const livenessWatchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: null,
probeTimeoutMs: RELAY_PROBE_TIMEOUT_MS,
missedProbeLimit: RELAY_MISSED_PROBE_LIMIT,
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
sendProbe: () =>
state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }),
terminate: () => fail(new Error('relay session liveness timeout'))
})
return client
async function confirmResume(): Promise<void> {
@@ -139,6 +166,7 @@ export function connectMobileRelayRpcSession(args: {
resumeConfirmation = result.resumeConfirmation
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
lastConnectedAt = Date.now()
livenessWatchdog.start(livenessIdentity)
publishState('connected')
} catch (error) {
fail(asError(error))
@@ -242,6 +270,7 @@ export function connectMobileRelayRpcSession(args: {
}
closed = true
failure = error
livenessWatchdog.stop(livenessIdentity)
link.close()
rejectPending(error)
publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected')
@@ -57,8 +57,10 @@ export class MobileRelayRpcStreams {
void this.options
.waitForConnected()
.then(() => {
if (!stream.cancelled && !this.options.sendFrame({ id, method, params: stream.params })) {
this.fail(id, stream, 'Connection interrupted')
if (!stream.cancelled) {
if (!this.options.sendFrame({ id, method, params: stream.params })) {
this.fail(id, stream, 'Connection interrupted')
}
}
})
.catch((error: unknown) => {
@@ -114,8 +116,7 @@ export class MobileRelayRpcStreams {
}
handleTerminalBinaryFrame(bytes, {
terminalSnapshots: this.terminalSnapshots,
getListener: (streamId) => this.terminalListeners.get(streamId),
recordValidatedInboundTraffic: () => {}
getListener: (streamId) => this.terminalListeners.get(streamId)
})
}
@@ -1,87 +0,0 @@
import type { ConnectionState } from './types'
// Why: RN auto-pongs pings natively, so JS needs an app-level probe to detect half-open sockets.
const ACTIVITY_PROBE_INTERVAL_MS = 20_000
const ACTIVITY_PROBE_TIMEOUT_MS = 8_000
export type RpcActivityProbeDependencies = {
getState: () => ConnectionState
getSocket: () => WebSocket | null
// Counts only validated inbound traffic, so a malformed frame cannot pass for liveness.
getInboundSequence: () => number
nextId: () => string
registerPending: (id: string, onSettled: () => void) => void
clearPending: (id: string) => void
sendProbe: (id: string) => boolean
forceReconnect: (socket: WebSocket) => void
}
export type RpcActivityProbe = {
start: () => void
stop: () => void
run: () => void
}
export function createRpcActivityProbe(
dependencies: RpcActivityProbeDependencies
): RpcActivityProbe {
let timer: ReturnType<typeof setInterval> | null = null
let inFlight = false
function stop(): void {
if (timer) {
clearInterval(timer)
timer = null
}
}
function run(): void {
const probeWs = dependencies.getSocket()
if (dependencies.getState() !== 'connected' || !probeWs || inFlight) {
return
}
inFlight = true
const id = dependencies.nextId()
const probeInboundSequence = dependencies.getInboundSequence()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
inFlight = false
dependencies.clearPending(id)
// Why: any validated frame answered for the socket, even if this probe did not.
if (dependencies.getInboundSequence() > probeInboundSequence) {
return
}
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
state: dependencies.getState()
})
// Why: stale probe timers must not close a replacement socket.
if (probeWs === dependencies.getSocket() && probeWs.readyState === WebSocket.OPEN) {
dependencies.forceReconnect(probeWs)
}
}, ACTIVITY_PROBE_TIMEOUT_MS)
dependencies.registerPending(id, () => {
if (timedOut) {
return
}
inFlight = false
clearTimeout(timeout)
})
if (!dependencies.sendProbe(id)) {
inFlight = false
clearTimeout(timeout)
dependencies.clearPending(id)
}
}
return {
run,
stop,
start(): void {
stop()
timer = setInterval(run, ACTIVITY_PROBE_INTERVAL_MS)
}
}
}
@@ -0,0 +1,28 @@
import type { HostClientAcquisition } from './host-client-acquisition-registry'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
export type RpcClientContextValue = {
acquire: (
hostId: string,
acquisition: HostClientAcquisition,
host?: HostProfile
) => RpcClient | null
release: (hostId: string, acquisition: HostClientAcquisition) => void
releaseAndCloseIfUnused: (hostId: string, acquisition: HostClientAcquisition) => void
closeIfUnused: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
refreshHostClient: (hostId: string) => void
forgetHostClient: (hostId: string) => void
disconnectHostClient: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getKnownState: (hostId: string) => ConnectionState | null
getReconnectAttempt: (hostId: string) => number
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => { hostId: string; client: RpcClient }[]
subscribeAllHosts: (listener: () => void) => () => void
primeHosts: (hosts: HostProfile[]) => void
}
@@ -151,14 +151,13 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => {
// comes back to the foreground.
blackhole = true
c.notifyForeground()
// Foreground probe budget is 8s; the interval probe alone would take
// up to 28s. Allow scheduling slack but stay well under 28s.
// Three fair probe windows tolerate transient mobile/Tailscale stalls.
const detectMs = await waitFor(
'half-open detected',
15_000,
32_000,
() => c.getState() !== 'connected'
)
expect(detectMs).toBeLessThan(12_000)
expect(detectMs).toBeLessThan(30_000)
blackhole = false
await waitFor('recovered after link healed', 15_000, () => c.getState() === 'connected')
@@ -18,18 +18,15 @@ function encodeFrame(opcode: TerminalStreamOpcode, streamId: number, payload: un
describe('handleTerminalBinaryFrame', () => {
it('routes terminal metadata frames to the stream listener', () => {
const listener = vi.fn()
const recordValidatedInboundTraffic = vi.fn()
handleTerminalBinaryFrame(
encodeFrame(TerminalStreamOpcode.Metadata, 42, { cwd: '/repo/src' }),
{
terminalSnapshots: new Map(),
getListener: (streamId) => (streamId === 42 ? listener : undefined),
recordValidatedInboundTraffic
getListener: (streamId) => (streamId === 42 ? listener : undefined)
}
)
expect(recordValidatedInboundTraffic).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith({ type: 'metadata', streamId: 42, cwd: '/repo/src' })
})
})
@@ -16,7 +16,6 @@ type StreamingListener = (result: unknown) => void
type TerminalBinaryFrameOptions = {
terminalSnapshots: Map<number, TerminalSnapshotState>
getListener: (streamId: number) => StreamingListener | undefined
recordValidatedInboundTraffic: () => void
}
export function handleTerminalBinaryFrame(
@@ -29,11 +28,9 @@ export function handleTerminalBinaryFrame(
}
const listener = options.getListener(frame.streamId)
if (!listener) {
options.recordValidatedInboundTraffic()
return
}
if (frame.opcode === TerminalStreamOpcode.Output) {
options.recordValidatedInboundTraffic()
listener({
type: 'data',
streamId: frame.streamId,
@@ -46,7 +43,6 @@ export function handleTerminalBinaryFrame(
if (!meta) {
return
}
options.recordValidatedInboundTraffic()
options.terminalSnapshots.set(frame.streamId, {
streamId: frame.streamId,
meta,
@@ -55,7 +51,6 @@ export function handleTerminalBinaryFrame(
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
options.recordValidatedInboundTraffic()
const snapshot = options.terminalSnapshots.get(frame.streamId)
if (!snapshot) {
return
@@ -64,7 +59,6 @@ export function handleTerminalBinaryFrame(
return
}
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
options.recordValidatedInboundTraffic()
const snapshot = options.terminalSnapshots.get(frame.streamId)
if (!snapshot) {
return
@@ -84,7 +78,6 @@ export function handleTerminalBinaryFrame(
if (!meta) {
return
}
options.recordValidatedInboundTraffic()
listener({
...meta,
type: 'resized',
@@ -97,7 +90,6 @@ export function handleTerminalBinaryFrame(
if (!meta) {
return
}
options.recordValidatedInboundTraffic()
listener({
...meta,
type: 'metadata',
@@ -106,7 +98,6 @@ export function handleTerminalBinaryFrame(
return
}
if (frame.opcode === TerminalStreamOpcode.Error) {
options.recordValidatedInboundTraffic()
listener({
type: 'error',
streamId: frame.streamId,
+22 -10
View File
@@ -687,7 +687,7 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
it('reaps a half-open socket within 8s of foreground', async () => {
it('reaps a half-open socket after three fair foreground probe windows', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
openAndAuthenticate(socket)
@@ -696,9 +696,8 @@ describe('mobile rpc-client connection timeout', () => {
client.notifyForeground()
expect(sentRequests(socket, 'status.get')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(8_000)
await vi.advanceTimersByTimeAsync(24_000)
expect(socket.close).toHaveBeenCalled()
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(500)
openAndAuthenticate(mockSockets[mockSockets.length - 1]!)
@@ -714,10 +713,9 @@ describe('mobile rpc-client connection timeout', () => {
socket.emitCloseOnClose = false
client.notifyForeground()
await vi.advanceTimersByTimeAsync(8_000)
await vi.advanceTimersByTimeAsync(24_000)
expect(socket.close).toHaveBeenCalledTimes(1)
expect(client.getState()).toBe('reconnecting')
socket.onclose?.()
expect(client.getState()).toBe('reconnecting')
@@ -740,7 +738,8 @@ describe('mobile rpc-client connection timeout', () => {
client.notifyForeground()
client.notifyForeground()
expect(sentRequests(socket, 'status.get')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(8_000)
await vi.advanceTimersByTimeAsync(24_000)
expect(sentRequests(socket, 'status.get')).toHaveLength(3)
expect(socket.close).toHaveBeenCalledTimes(1)
expect(client.getState()).toBe('reconnecting')
@@ -748,6 +747,7 @@ describe('mobile rpc-client connection timeout', () => {
expect(mockSockets).toHaveLength(2)
client.close()
})
it('keeps a healthy connection when the foreground probe is answered', async () => {
const { client, socket } = connectAuthenticated()
@@ -782,13 +782,14 @@ describe('mobile rpc-client connection timeout', () => {
await vi.advanceTimersByTimeAsync(8_000)
expect(socket.close).not.toHaveBeenCalled()
expect(client.getState()).toBe('connected')
await vi.advanceTimersByTimeAsync(12_000)
expect(sentRequests(socket, 'status.get')).toHaveLength(2)
await vi.advanceTimersByTimeAsync(7_999)
expect(socket.close).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(socket.close).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(16_000)
expect(socket.close).toHaveBeenCalled()
expect(client.getState()).toBe('reconnecting')
@@ -853,18 +854,29 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
it('does not count malformed or undecryptable inbound payloads as probe activity', async () => {
it('counts authenticated unknown payloads before semantic decoding', async () => {
const { client, socket } = connectAuthenticated()
client.notifyForeground()
socket.receive('undecryptable')
socket.receive('encrypted:{"unexpected":true}')
socket.receive('encrypted:{"id":"rpc-incomplete","ok":true}')
socket.receive(new Uint8Array([0xff, 0x00, 0x01]))
await vi.advanceTimersByTimeAsync(8_000)
expect(socket.close).not.toHaveBeenCalled()
expect(client.getState()).toBe('connected')
client.close()
})
it('does not count undecryptable payloads as probe activity', async () => {
const { client, socket } = connectAuthenticated()
client.notifyForeground()
socket.receive('undecryptable')
await vi.advanceTimersByTimeAsync(24_000)
expect(socket.close).toHaveBeenCalled()
expect(client.getState()).toBe('reconnecting')
client.close()
})
+77 -63
View File
@@ -37,7 +37,14 @@ import {
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import { isRpcResponse } from './rpc-response-shape'
import { createRpcActivityProbe } from './rpc-client-activity-probe'
import {
isStreamingSubscriptionReadyResult,
isTerminalSubscribedResult
} from './rpc-subscription-result-shapes'
import {
RpcSessionLivenessWatchdog,
type RpcSessionIdentity
} from './rpc-session-liveness-watchdog'
import { isStaleForegroundDial } from './rpc-stale-dial'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
@@ -54,7 +61,6 @@ type ConnectWaiter = {
export type SendRequestOptions = {
timeoutMs?: number
expectedRuntimeId?: string
/** Spend `timeoutMs` across connect-wait AND the request instead of giving each
* phase its own. Interactive chat writes need it: they run as sequential loops
* under one shared budget, so a per-phase clock lets the composer sit `sending`
@@ -130,6 +136,7 @@ const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
// Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes.
const WEBSOCKET_CONNECTING_STATE = 0
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
export type ConnectOptions = {
onStateChange?: (state: ConnectionState) => void
@@ -178,7 +185,7 @@ export function connect(
let lastConnectedAt: number | null = null
// Why: cheap diagnostics for RN/OkHttp process-state poisoning (retry cadence, inbound traffic, close timing).
let lastInboundAt: number | null = null
let inboundSequence = 0
let livenessIdentity: (RpcSessionIdentity & { socket: WebSocket }) | null = null
let lastWsClosedAt: number | null = null
let wsConstructionCounter = 0
let dialStartedAt = 0
@@ -316,6 +323,7 @@ export function connect(
const openingWs = ws
let openingWsAuthenticated = false
let openingWsLastInboundAt: number | null = null
let openingLivenessIdentity: (RpcSessionIdentity & { socket: WebSocket }) | null = null
// Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives.
connectTimer = setTimeout(() => {
@@ -352,7 +360,12 @@ export function connect(
type: 'e2ee_hello',
publicKeyB64: publicKeyToBase64(ephemeral.publicKey)
})
openingWs.send(hello)
try {
openingWs.send(hello)
} catch {
closeAndSynthesize(openingWs)
return
}
emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready')
sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey)
@@ -420,9 +433,11 @@ export function connect(
streamCount: streamListeners.size
})
openingWsAuthenticated = true
openingLivenessIdentity = { socket: openingWs }
livenessIdentity = openingLivenessIdentity
livenessWatchdog.start(openingLivenessIdentity)
setState('connected')
emitLog('success', 'Authenticated', 'Channel ready for RPC')
activityProbe.start()
for (const [id, stream] of streamListeners) {
if (stream.cancelled) {
removeStreamListener(id)
@@ -442,8 +457,9 @@ export function connect(
) {
stream.sent = true
} else {
emitStreamError(stream, 'Connection interrupted')
removeStreamListener(id)
// The failed write already starts recovery; retain every stream for replay.
markStreamsForReplay()
break
}
}
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
@@ -474,6 +490,9 @@ export function connect(
if (!plaintextBytes) {
return
}
if (openingLivenessIdentity) {
livenessWatchdog.noteAuthenticatedInbound(openingLivenessIdentity)
}
handleBinaryFrame(plaintextBytes)
return
}
@@ -482,6 +501,9 @@ export function connect(
if (plaintext === null) {
return
}
if (openingLivenessIdentity) {
livenessWatchdog.noteAuthenticatedInbound(openingLivenessIdentity)
}
let response: unknown
try {
@@ -492,8 +514,9 @@ export function connect(
if (!isRpcResponse(response)) {
return
}
recordValidatedInboundTraffic()
if (response.id.startsWith(LIVENESS_REQUEST_ID_PREFIX)) {
return
}
// Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed.
if (!response.ok && response.error.code === 'unauthorized') {
handleAuthRejection('Unauthorized — pairing may be revoked')
@@ -642,7 +665,10 @@ export function connect(
pendingBrowserScreencastRequestId = null
markStreamsForReplay()
clearHandshakeTimer()
activityProbe.stop()
if (livenessIdentity?.socket === closedWs) {
livenessWatchdog.stop(livenessIdentity)
livenessIdentity = null
}
if (intentionallyClosed) {
console.log('[net] handleSocketClosed — intentional close')
setState('disconnected')
@@ -673,6 +699,10 @@ export function connect(
// Why: an auth rejection may be transient (issue #5200) — retry up to AUTH_RETRY_BUDGET times before latching auth-failed.
function handleAuthRejection(reason: string, preserveRecovery = false): void {
if (livenessIdentity) {
livenessWatchdog.stop(livenessIdentity)
livenessIdentity = null
}
authRejectionCount++
if (authRejectionCount < AUTH_RETRY_BUDGET) {
console.log('[net] auth rejected — retrying handshake', {
@@ -878,21 +908,15 @@ export function connect(
}
}
function recordValidatedInboundTraffic(): void {
inboundSequence++
}
function handleBinaryFrame(bytes: Uint8Array): void {
const browserFrame = decodeBrowserScreencastFrame(bytes)
if (browserFrame) {
recordValidatedInboundTraffic()
handleBrowserBinaryFrame(browserFrame)
return
}
handleTerminalBinaryFrame(bytes, {
terminalSnapshots,
getListener: (streamId) => terminalStreamListeners.get(streamId),
recordValidatedInboundTraffic
getListener: (streamId) => terminalStreamListeners.get(streamId)
})
}
@@ -909,8 +933,16 @@ export function connect(
function sendEncrypted(request: unknown): boolean {
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
ws.send(encrypt(JSON.stringify(request), sharedKey))
return true
const sendingWs = ws
try {
sendingWs.send(encrypt(JSON.stringify(request), sharedKey))
return true
} catch {
if (ws === sendingWs) {
closeAndSynthesize(sendingWs)
}
return false
}
}
console.log('[net] sendEncrypted FAILED — channel not ready', {
hasWs: !!ws,
@@ -956,15 +988,23 @@ export function connect(
}
}
const activityProbe = createRpcActivityProbe({
getState: () => state,
getSocket: () => ws,
getInboundSequence: () => inboundSequence,
nextId,
registerPending: (id, onSettled) => pending.set(id, { resolve: onSettled, reject: onSettled }),
clearPending: (id) => pending.delete(id),
sendProbe: (id) => sendEncrypted({ id, deviceToken, method: 'status.get' }),
forceReconnect: closeAndSynthesize
const livenessWatchdog = new RpcSessionLivenessWatchdog({
transport: 'direct',
sendProbe: (identity) => {
if (identity !== livenessIdentity || state !== 'connected') {
return false
}
return sendEncrypted({
id: `${LIVENESS_REQUEST_ID_PREFIX}${nextId()}`,
deviceToken,
method: 'status.get'
})
},
terminate: (identity) => {
if (identity === livenessIdentity && livenessIdentity.socket === ws) {
closeAndSynthesize(livenessIdentity.socket)
}
}
})
openConnection()
@@ -1014,15 +1054,7 @@ export function connect(
}
})
if (
!sendEncrypted({
id,
deviceToken,
method,
params,
...(options?.expectedRuntimeId ? { expectedRuntimeId: options.expectedRuntimeId } : {})
})
) {
if (!sendEncrypted({ id, deviceToken, method, params })) {
pending.delete(id)
clearTimeout(timeout)
reject(new Error('Connection interrupted'))
@@ -1128,10 +1160,11 @@ export function connect(
return
}
if (state === 'connected') {
// Why: OS can kill the TCP path while backgrounded without onclose; probe now to detect the half-open socket in ≤8s (issue #5049).
// Why: resume probes now; three fair misses detect a half-open socket within 24s.
console.log('[net] foreground — probing live connection')
activityProbe.start()
activityProbe.run()
if (livenessIdentity) {
livenessWatchdog.probeNow(livenessIdentity)
}
return
}
const dialing = ws
@@ -1166,7 +1199,10 @@ export function connect(
}
clearConnectTimer()
clearHandshakeTimer()
activityProbe.stop()
if (livenessIdentity) {
livenessWatchdog.stop(livenessIdentity)
livenessIdentity = null
}
if (ws) {
ws.close()
ws = null
@@ -1178,25 +1214,3 @@ export function connect(
}
}
}
function isTerminalSubscribedResult(
value: unknown
): value is { type: 'subscribed'; streamId: number } {
return (
!!value &&
typeof value === 'object' &&
(value as { type?: unknown }).type === 'subscribed' &&
typeof (value as { streamId?: unknown }).streamId === 'number'
)
}
function isStreamingSubscriptionReadyResult(
value: unknown
): value is { type: 'ready'; subscriptionId: string } {
return (
!!value &&
typeof value === 'object' &&
(value as { type?: unknown }).type === 'ready' &&
typeof (value as { subscriptionId?: unknown }).subscriptionId === 'string'
)
}
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
decrypt: (raw: string) => raw.replace(/^encrypted:/, ''),
decryptBytes: (bytes: Uint8Array) => bytes
}))
class MockWebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSED = MockWebSocket.CLOSED
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onclose: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
throwOnSend = false
throwForMethod: string | null = null
readonly sent: string[] = []
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
})
constructor() {
sockets.push(this)
}
send(payload: string): void {
if (
this.throwOnSend ||
(this.throwForMethod && payload.includes(`"method":"${this.throwForMethod}"`))
) {
throw new Error('socket send failed')
}
this.sent.push(payload)
}
authenticate(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
this.onmessage?.({ data: JSON.stringify({ type: 'e2ee_ready' }) })
this.onmessage?.({ data: 'encrypted:{"type":"e2ee_authenticated"}' })
}
}
const sockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
vi.useRealTimers()
globalThis.WebSocket = originalWebSocket
})
describe('physical session liveness', () => {
it('recovers when the initial handshake write races socket teardown', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = sockets[0]!
socket.throwOnSend = true
try {
expect(() => socket.authenticate()).not.toThrow()
expect(socket.close).toHaveBeenCalledOnce()
expect(client.getState()).toBe('reconnecting')
} finally {
client.close()
}
})
it('tolerates the first fair silent foreground-probe window', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = sockets[0]!
socket.authenticate()
try {
client.notifyForeground()
await vi.advanceTimersByTimeAsync(8_000)
expect(socket.close).not.toHaveBeenCalled()
expect(client.getState()).toBe('connected')
} finally {
client.close()
}
})
it('counts authenticated terminal binary output as liveness', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = sockets[0]!
socket.authenticate()
client.notifyForeground()
socket.onmessage?.({
data: encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId: 42,
seq: 1,
payload: new TextEncoder().encode('hello')
})
})
await Promise.resolve()
await vi.advanceTimersByTimeAsync(8_000)
expect(socket.close).not.toHaveBeenCalled()
expect(client.getState()).toBe('connected')
client.close()
})
it('turns a probe write exception into session recovery', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = sockets[0]!
socket.authenticate()
socket.throwOnSend = true
try {
expect(() => client.notifyForeground()).not.toThrow()
expect(socket.close).toHaveBeenCalledOnce()
expect(client.getState()).toBe('reconnecting')
} finally {
client.close()
}
})
it('retains every queued stream when replay write fails', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const first = sockets[0]!
const disposeFirst = client.subscribe('terminal.subscribe', { terminal: 'term-1' }, vi.fn())
const disposeSecond = client.subscribe('terminal.subscribe', { terminal: 'term-2' }, vi.fn())
first.throwForMethod = 'terminal.subscribe'
first.authenticate()
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(500)
const replacement = sockets[1]!
replacement.authenticate()
expect(
replacement.sent.filter((payload) => payload.includes('terminal.subscribe'))
).toHaveLength(2)
disposeFirst()
disposeSecond()
client.close()
})
it('does not let probe replies consume the authentication retry budget', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = sockets[0]!
socket.authenticate()
for (let index = 0; index < 3; index++) {
client.notifyForeground()
const request = socket.sent
.map((payload) => payload.replace(/^encrypted:/, ''))
.map((payload) => JSON.parse(payload) as { id?: string; method?: string })
.findLast((payload) => payload.method === 'status.get')
socket.onmessage?.({
data: `encrypted:${JSON.stringify({
id: request?.id,
ok: false,
error: { code: 'unauthorized', message: 'Unauthorized' }
})}`
})
}
expect(client.getState()).toBe('connected')
expect(sockets).toHaveLength(1)
client.close()
})
})
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LIVENESS_IDLE_MS,
LIVENESS_PROBE_TIMEOUT_MS,
RpcSessionLivenessWatchdog
} from './rpc-session-liveness-watchdog'
describe('RpcSessionLivenessWatchdog', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
function fixture() {
const sendProbe = vi.fn(() => true)
const terminate = vi.fn()
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'direct',
sendProbe,
terminate,
now: Date.now
})
const identity = {}
watchdog.start(identity)
return { identity, sendProbe, terminate, watchdog }
}
it('probes only after authenticated-inbound idle', async () => {
const { identity, sendProbe, watchdog } = fixture()
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS - 1)
expect(sendProbe).not.toHaveBeenCalled()
watchdog.noteAuthenticatedInbound(identity)
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS - 1)
expect(sendProbe).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(sendProbe).toHaveBeenCalledOnce()
})
it('requires three fair consecutive misses', async () => {
const { identity, terminate, watchdog } = fixture()
watchdog.probeNow(identity)
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS * 2)
expect(terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS)
expect(terminate).toHaveBeenCalledOnce()
expect(terminate).toHaveBeenCalledWith(identity)
})
it('authenticated activity resets suspicion', async () => {
const { identity, terminate, watchdog } = fixture()
watchdog.probeNow(identity)
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS)
watchdog.noteAuthenticatedInbound(identity)
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS + LIVENESS_PROBE_TIMEOUT_MS * 2)
expect(terminate).not.toHaveBeenCalled()
})
it('does not churn timers during continuous authenticated traffic', async () => {
const setTimer = vi.fn(setTimeout)
const sendProbe = vi.fn(() => true)
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'direct',
sendProbe,
terminate: vi.fn(),
now: Date.now,
setTimer
})
const identity = {}
watchdog.start(identity)
await vi.advanceTimersByTimeAsync(10_000)
for (let index = 0; index < 100; index++) {
watchdog.noteAuthenticatedInbound(identity)
}
expect(setTimer).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(10_000)
expect(sendProbe).not.toHaveBeenCalled()
expect(setTimer).toHaveBeenCalledTimes(2)
})
it('does not charge a scheduler-stalled probe window', () => {
let now = 0
let callback: (() => void) | null = null
const terminate = vi.fn()
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: null,
probeTimeoutMs: 4_000,
missedProbeLimit: 2,
sendProbe: () => true,
terminate,
now: () => now,
setTimer: (next) => {
callback = next
return 1 as unknown as ReturnType<typeof setTimeout>
},
clearTimer: () => {}
})
const identity = {}
watchdog.start(identity)
watchdog.probeNow(identity)
now = 8_000
callback?.()
now += 4_000
callback?.()
expect(terminate).not.toHaveBeenCalled()
})
it('invalidates late callbacks after identity replacement', () => {
const callbacks: (() => void)[] = []
const terminate = vi.fn()
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'direct',
sendProbe: () => true,
terminate,
now: () => 0,
setTimer: (callback) => {
callbacks.push(callback)
return callbacks.length as unknown as ReturnType<typeof setTimeout>
},
clearTimer: () => {}
})
const first = {}
const replacement = {}
watchdog.start(first)
watchdog.probeNow(first)
watchdog.start(replacement)
callbacks.forEach((callback) => callback())
expect(terminate).not.toHaveBeenCalled()
})
it('terminates immediately when a probe cannot be written', () => {
const terminate = vi.fn()
const identity = {}
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'direct',
sendProbe: () => false,
terminate
})
watchdog.start(identity)
watchdog.probeNow(identity)
expect(terminate).toHaveBeenCalledWith(identity)
})
})
@@ -0,0 +1,189 @@
export const LIVENESS_IDLE_MS = 20_000
export const LIVENESS_PROBE_TIMEOUT_MS = 8_000
export const MISSED_PROBE_LIMIT = 3
export type RpcSessionIdentity = object
type WatchdogOptions = {
transport: 'direct' | 'relay'
sendProbe: (identity: RpcSessionIdentity) => boolean
terminate: (identity: RpcSessionIdentity) => void
idleProbeMs?: number | null
probeTimeoutMs?: number
missedProbeLimit?: number
voluntaryProbeMinIntervalMs?: number
now?: () => number
setTimer?: typeof setTimeout
clearTimer?: typeof clearTimeout
}
export class RpcSessionLivenessWatchdog {
private identity: RpcSessionIdentity | null = null
private timer: ReturnType<typeof setTimeout> | null = null
private probing = false
private missedProbes = 0
private lastInboundAt = 0
private lastVoluntaryProbeAt: number | null = null
private readonly idleProbeMs: number | null
private readonly probeTimeoutMs: number
private readonly missedProbeLimit: number
private readonly voluntaryProbeMinIntervalMs: number
private readonly now: () => number
private readonly setTimer: typeof setTimeout
private readonly clearTimer: typeof clearTimeout
constructor(private readonly options: WatchdogOptions) {
this.idleProbeMs = options.idleProbeMs === undefined ? LIVENESS_IDLE_MS : options.idleProbeMs
this.probeTimeoutMs = options.probeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS
this.missedProbeLimit = options.missedProbeLimit ?? MISSED_PROBE_LIMIT
this.voluntaryProbeMinIntervalMs = options.voluntaryProbeMinIntervalMs ?? 0
this.now = options.now ?? Date.now
this.setTimer = options.setTimer ?? setTimeout
this.clearTimer = options.clearTimer ?? clearTimeout
}
start(identity: RpcSessionIdentity): void {
this.clearActiveTimer()
this.identity = identity
this.probing = false
this.missedProbes = 0
this.lastInboundAt = this.now()
this.lastVoluntaryProbeAt = null
this.armIdle(identity)
}
noteAuthenticatedInbound(identity: RpcSessionIdentity): void {
if (this.identity !== identity) {
return
}
this.lastInboundAt = this.now()
if (this.missedProbes > 0) {
console.log('[net] activity-probe recovered', {
transport: this.options.transport,
priorMissedProbes: this.missedProbes
})
}
if (!this.probing && this.missedProbes === 0) {
return
}
this.missedProbes = 0
this.probing = false
this.armIdle(identity)
}
probeNow(identity: RpcSessionIdentity): void {
if (this.identity !== identity || this.probing) {
return
}
const now = this.now()
if (
this.lastVoluntaryProbeAt !== null &&
now - this.lastVoluntaryProbeAt < this.voluntaryProbeMinIntervalMs
) {
return
}
this.lastVoluntaryProbeAt = now
this.startProbe(identity)
}
stop(identity: RpcSessionIdentity): void {
if (this.identity !== identity) {
return
}
this.clearActiveTimer()
this.identity = null
this.probing = false
this.missedProbes = 0
this.lastInboundAt = 0
this.lastVoluntaryProbeAt = null
}
private armIdle(identity: RpcSessionIdentity, delayMs = this.idleProbeMs): void {
this.clearActiveTimer()
if (delayMs === null) {
return
}
this.timer = this.setTimer(() => {
this.timer = null
if (this.identity !== identity) {
return
}
const idleMs = this.now() - this.lastInboundAt
if (this.idleProbeMs !== null && idleMs < this.idleProbeMs) {
this.armIdle(identity, Math.max(1, this.idleProbeMs - Math.max(0, idleMs)))
} else {
this.startProbe(identity)
}
}, delayMs)
}
private startProbe(identity: RpcSessionIdentity): void {
if (this.identity !== identity) {
return
}
this.clearActiveTimer()
this.probing = true
const sentAt = this.now()
let sent = false
try {
sent = this.options.sendProbe(identity)
} catch {
sent = false
}
if (!sent) {
this.terminateCurrent(identity)
return
}
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
}
private handleProbeTimeout(identity: RpcSessionIdentity, sentAt: number): void {
this.timer = null
if (this.identity !== identity) {
return
}
const elapsedMs = this.now() - sentAt
if (elapsedMs < 0 || elapsedMs > this.probeTimeoutMs * 1.5) {
console.log('[net] activity-probe unfair window skipped', {
transport: this.options.transport,
elapsedMs,
timeoutMs: this.probeTimeoutMs
})
this.startProbe(identity)
return
}
this.missedProbes += 1
if (this.missedProbes >= this.missedProbeLimit) {
this.terminateCurrent(identity)
return
}
console.log('[net] activity-probe timeout tolerated', {
transport: this.options.transport,
missedProbes: this.missedProbes,
missedProbeLimit: this.missedProbeLimit
})
this.startProbe(identity)
}
private terminateCurrent(identity: RpcSessionIdentity): void {
if (this.identity !== identity) {
return
}
this.clearActiveTimer()
this.identity = null
this.probing = false
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
transport: this.options.transport,
missedProbes: this.missedProbes,
missedProbeLimit: this.missedProbeLimit
})
this.options.terminate(identity)
}
private clearActiveTimer(): void {
if (this.timer !== null) {
this.clearTimer(this.timer)
this.timer = null
}
}
}
@@ -0,0 +1,21 @@
export function isTerminalSubscribedResult(
value: unknown
): value is { type: 'subscribed'; streamId: number } {
return (
!!value &&
typeof value === 'object' &&
(value as { type?: unknown }).type === 'subscribed' &&
typeof (value as { streamId?: unknown }).streamId === 'number'
)
}
export function isStreamingSubscriptionReadyResult(
value: unknown
): value is { type: 'ready'; subscriptionId: string } {
return (
!!value &&
typeof value === 'object' &&
(value as { type?: unknown }).type === 'ready' &&
typeof (value as { subscriptionId?: unknown }).subscriptionId === 'string'
)
}
@@ -554,7 +554,7 @@ describe('settings host client lifecycle', () => {
}
const originalClient = clients.get(replacedHostId)?.[0]
act(() => context?.closeHost(replacedHostId))
act(() => context?.refreshHostClient(replacedHostId))
expect(originalClient?.closeMock).toHaveBeenCalledOnce()
await act(async () => {
@@ -146,8 +146,9 @@ describe('stable logical RPC client', () => {
await expect(client.sendRequest('status.get')).resolves.toEqual(success('next'))
})
it('lets the physical close settle in-flight requests on suspend, preserving delivery marks', async () => {
it('preserves delivery ambiguity without replaying a mutation after relay replacement', async () => {
const session = new FakeSession('connected')
const replacement = new FakeSession('connected')
const inFlight = deferred<RpcResponse>()
session.sendRequest.mockReturnValue(inFlight.promise)
// Mirror the real physical contract: close() rejects post-write pendings
@@ -161,8 +162,10 @@ describe('stable logical RPC client', () => {
await expect(request).rejects.toBe(closeError)
await expect(request.catch((error: unknown) => isRpcDeliveryUnknown(error))).resolves.toBe(true)
// New requests while suspended still fail definitively before any write.
await expect(client.sendRequest('status.get')).rejects.toThrow('Client suspended')
await client.migrateTo(replacement, 'relay')
expect(replacement.sendRequest).not.toHaveBeenCalled()
replacement.sendRequest.mockResolvedValue(success('next'))
await expect(client.sendRequest('status.get')).resolves.toEqual(success('next'))
})
it('lets the physical close settle in-flight requests on close, keeping pre-write failures definite', async () => {
-1
View File
@@ -18,7 +18,6 @@ export type RpcRequest = {
deviceToken: string
method: string
params?: unknown
expectedRuntimeId?: string
}
export type RpcSuccess = {
+27 -7
View File
@@ -3,6 +3,7 @@ import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
import { useRpcClientContext } from './client-context'
import type { HostClientAcquisition } from './client-context'
type UseAllHostClientsOptions = {
autoConnectHostIds?: readonly string[]
@@ -24,6 +25,7 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
)
const [tick, setTick] = useState(0)
const acquiredHostIdsRef = useRef<Set<string>>(new Set())
const acquisitionsRef = useRef<Map<string, HostClientAcquisition>>(new Map())
const hostUnsubscribesRef = useRef<Map<string, () => void>>(new Map())
const closeUnusedRef = useRef(closeUnusedOnRelease)
@@ -42,10 +44,17 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
}
hostUnsubscribesRef.current.clear()
for (const id of acquiredHostIds) {
const acquisition = acquisitionsRef.current.get(id)
if (!acquisition) {
if (closeUnusedRef.current) {
ctx.closeIfUnused(id)
}
continue
}
if (closeUnusedRef.current) {
ctx.releaseAndCloseIfUnused(id)
ctx.releaseAndCloseIfUnused(id, acquisition)
} else {
ctx.release(id)
ctx.release(id, acquisition)
}
}
if (closeUnusedRef.current) {
@@ -56,6 +65,7 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
}
}
acquiredHostIdsRef.current.clear()
acquisitionsRef.current.clear()
}
}, [ctx])
@@ -82,16 +92,26 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
for (const id of acquiredHostIdsRef.current) {
if (!nextAcquiredHostIds.has(id)) {
if (closeUnusedOnRelease) {
ctx.releaseAndCloseIfUnused(id)
} else {
ctx.release(id)
const acquisition = acquisitionsRef.current.get(id)
if (!acquisition) {
if (closeUnusedOnRelease) {
ctx.closeIfUnused(id)
}
continue
}
if (closeUnusedOnRelease) {
ctx.releaseAndCloseIfUnused(id, acquisition)
} else {
ctx.release(id, acquisition)
}
acquisitionsRef.current.delete(id)
}
}
for (const id of nextAcquiredHostIds) {
if (!acquiredHostIdsRef.current.has(id)) {
ctx.acquire(id)
const acquisition = {}
acquisitionsRef.current.set(id, acquisition)
ctx.acquire(id, acquisition)
}
}
if (closeUnusedOnRelease) {
+3
View File
@@ -84,11 +84,14 @@
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
"test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs",
+1 -1
View File
@@ -30,4 +30,4 @@ export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"
ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"
+1 -1
View File
@@ -38,4 +38,4 @@ export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"
ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"
+37 -2
View File
@@ -40,6 +40,11 @@ in the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no se
`connection.type:"ssh"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`
output shape and half the templates.
Keep Orca's checkout behavior unchanged by default: omit `checkoutMode`, emit schema version 1, and
let Orca create a linked worktree. Only use `checkoutMode: provisioned-root` when the user explicitly
wants one ephemeral machine to clone the finished workspace itself. This niche mode currently requires
direct SSH, an ordinary non-bare/non-sparse primary checkout at `projectRoot`, and schema version 2.
**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,
git auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the
base-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire
@@ -60,6 +65,8 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
- **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs
`orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to
the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.
- **Checkout ownership:** do not ask by default. Only when the user requires the environment to
create the exact final checkout, confirm `provisioned-root` and direct SSH; otherwise omit it.
- **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also
ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or
`<cli> --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.
@@ -266,7 +273,7 @@ set -euo pipefail
set -euo pipefail
# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)
# fail clearly if snapshotId is missing (point back to Phases 2–3)
# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)
# name = orca-${ORCA_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)
# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address
# (an externally reachable wss:// URL); trap: remove sandbox on error
# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)
@@ -369,7 +376,12 @@ set -euo pipefail
vercel_args=(); [ -n "$scope" ] && vercel_args+=(--scope "$scope"); [ -n "$project" ] && vercel_args+=(--project "$project")
[ -n "$snapshot_id" ] || { echo "snapshotId missing — run Phases 2–3 first" >&2; exit 1; }
gh_token="${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}"
name="orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}" # sanitize+cap to 63 chars
recipe_id="${ORCA_RECIPE_ID:-vercel-sandbox}"
recipe_id="${recipe_id//./-}" # Vercel names forbid dots.
instance_id="${ORCA_VM_INSTANCE_ID:-$(date +%s)}"
max_recipe_id_length=$((128 - ${#instance_id} - 6)) # Preserve the unique instance suffix.
[ "$max_recipe_id_length" -gt 0 ] || { echo "ORCA_VM_INSTANCE_ID is too long for a Vercel sandbox name" >&2; exit 1; }
name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"
# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.
cleanup_on_error() { [ "$?" -ne 0 ] && vercel sandbox remove "$name" "${vercel_args[@]}" >/dev/null 2>&1 || true; }
@@ -457,6 +469,24 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.
For an explicitly requested one-VM-per-workspace checkout, the create script must read
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH`, create
that exact primary checkout at `projectRoot`, and emit the same SSH result with:
```json
{
"schemaVersion": 2,
"checkoutMode": "provisioned-root",
"connection": {
"type": "ssh",
"projectRoot": "/abs/repo",
"target": { "label": "my-box", "host": "192.0.2.10", "port": 22, "username": "ubuntu" }
}
}
```
Fail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.
**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
`orca serve` URL in SSH mode):
@@ -613,6 +643,11 @@ and `userData` are optional.
**SSH mode** — do **not** run `orca serve`; print the `connection.type:"ssh"` block instead (full shape +
worked script in §7g). `pairingCode` is **not** used in SSH mode.
**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add
`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create
the requested branch from `ORCA_REPO_REF` at the returned `projectRoot`, and emit schema version 2 plus
`checkoutMode: "provisioned-root"`. All recipes without this field retain the schema-v1 behavior above.
Lifecycle hooks (all run locally):
- `create`: required. Prints recipe result JSON.
+1 -1
View File
@@ -381,7 +381,7 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding
- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:
`orca orchestration send --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a" --report-path "<optional>" --json`
- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.
- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.
- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.
- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:
`orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","phase":"implementing"}' --json`
- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -179,7 +179,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
},
{
name: 'agent-hooks',
keys: ['agent hooks status', 'agent hooks off', 'agent hooks on'],
keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'],
load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS
},
{
+83 -3
View File
@@ -10,7 +10,8 @@ const {
callMock,
getCliStatusMock,
getDefaultUserDataPathMock,
getManagedAgentHookStatusesMock
getManagedAgentHookStatusesMock,
prepareManagedCodexHomeBeforeShellLaunchMock
} = vi.hoisted(() => ({
applyAgentStatusHooksEnabledMock: vi.fn(),
callMock: vi.fn(),
@@ -27,7 +28,8 @@ const {
})
),
getDefaultUserDataPathMock: vi.fn(),
getManagedAgentHookStatusesMock: vi.fn()
getManagedAgentHookStatusesMock: vi.fn(),
prepareManagedCodexHomeBeforeShellLaunchMock: vi.fn()
}))
vi.mock('../runtime-client', () => {
@@ -54,7 +56,8 @@ vi.mock('../runtime-client', () => {
vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock,
getManagedAgentHookStatuses: getManagedAgentHookStatusesMock
getManagedAgentHookStatuses: getManagedAgentHookStatusesMock,
prepareManagedCodexHomeBeforeShellLaunch: prepareManagedCodexHomeBeforeShellLaunchMock
}))
import { main } from '../index'
@@ -82,6 +85,7 @@ describe('agent hooks CLI handler', () => {
callMock.mockReset()
getCliStatusMock.mockClear()
getManagedAgentHookStatusesMock.mockReturnValue([])
prepareManagedCodexHomeBeforeShellLaunchMock.mockReset()
process.exitCode = undefined
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -120,4 +124,80 @@ describe('agent hooks CLI handler', () => {
expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true)
})
it('prepares managed Codex trust with the current hooks setting', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.agentStatusHooksEnabled = false
writeDataFile(userDataPath, state)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
})
it('honors Codex-specific disablement when the runtime is unavailable', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.disabledTuiAgents = ['codex']
writeDataFile(userDataPath, state)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
})
it('uses the active profile settings instead of stale legacy settings', async () => {
const profileId = 'work-profile'
const legacy = getDefaultPersistedState(userDataPath)
legacy.settings.agentStatusHooksEnabled = true
writeDataFile(userDataPath, legacy)
const profile = getDefaultPersistedState(userDataPath)
profile.settings.agentStatusHooksEnabled = false
writeDataFile(join(userDataPath, 'profiles', profileId), profile)
writeFileSync(
join(userDataPath, 'orca-profile-index.json'),
JSON.stringify({
activeProfileId: profileId,
profiles: [{ id: profileId }]
}),
'utf-8'
)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
})
it('honors live hook and Codex-specific disablement before persistence settles', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.agentStatusHooksEnabled = true
writeDataFile(userDataPath, state)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
callMock.mockResolvedValue({
result: {
settings: { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] }
}
})
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
expect(callMock).toHaveBeenCalledExactlyOnceWith('settings.get', undefined, {
timeoutMs: 1_000
})
})
})
+61 -5
View File
@@ -12,10 +12,12 @@ import {
} from '../runtime-client'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { getDefaultPersistedState } from '../../shared/constants'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses
getManagedAgentHookStatuses,
prepareManagedCodexHomeBeforeShellLaunch
} from '../../main/agent-hooks/managed-agent-hook-controls'
type AgentHookCommandResult = {
@@ -26,7 +28,27 @@ type AgentHookCommandResult = {
}
function getDataPath(): string {
return join(getDefaultUserDataPath(), 'orca-data.json')
const userDataPath = getDefaultUserDataPath()
const indexPath = join(userDataPath, 'orca-profile-index.json')
for (const candidate of [indexPath, `${indexPath}.bak`]) {
try {
const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8'))
if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) {
continue
}
const profileId = parsed.activeProfileId
if (
typeof profileId === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) &&
parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId)
) {
return join(userDataPath, 'profiles', profileId, 'orca-data.json')
}
} catch {
// Try the profile-index backup, then the legacy pre-profile path.
}
}
return join(userDataPath, 'orca-data.json')
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -70,9 +92,35 @@ function writePersistedState(dataPath: string, state: PersistedState): void {
}
}
function readEnabledFromDisk(): boolean {
function readHookSettingsFromDisk(): Pick<
GlobalSettings,
'agentStatusHooksEnabled' | 'disabledTuiAgents'
> {
const state = readPersistedState(getDataPath())
return state.settings?.agentStatusHooksEnabled !== false
return {
agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false,
disabledTuiAgents: normalizeDisabledTuiAgents(state.settings?.disabledTuiAgents)
}
}
async function readHookSettings(
client: RuntimeClient
): Promise<Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>> {
try {
const response = await client.call<{
settings?: Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>
}>('settings.get', undefined, { timeoutMs: 1_000 })
const settings = response.result.settings
if (settings && typeof settings.agentStatusHooksEnabled === 'boolean') {
return {
agentStatusHooksEnabled: settings.agentStatusHooksEnabled,
disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents)
}
}
} catch {
// The active profile on disk is the offline fallback.
}
return readHookSettingsFromDisk()
}
function updateEnabledOnDisk(enabled: boolean): {
@@ -157,9 +205,17 @@ async function setAgentHooksEnabled(
}
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
'agent hooks prepare-codex': async ({ client }) => {
const settings = await readHookSettings(client)
prepareManagedCodexHomeBeforeShellLaunch({
userDataPath: getDefaultUserDataPath(),
hooksEnabled:
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
})
},
'agent hooks status': async ({ json }) => {
const result: AgentHookCommandResult = {
enabled: readEnabledFromDisk(),
enabled: readHookSettingsFromDisk().agentStatusHooksEnabled,
settingsPath: getDataPath(),
appliedBy: 'offline',
statuses: getManagedAgentHookStatuses()
+1 -1
View File
@@ -1,7 +1,7 @@
import type {
LinearMcpIssueListRequest,
LinearMcpIssueListResult
} from '../../shared/linear-agent-access'
} from '../../shared/linear/agent-access'
import type { CommandHandler } from '../dispatch'
import {
getOptionalNonNegativeIntegerFlag,

Some files were not shown because too many files have changed in this diff Show More