Merge remote-tracking branch 'origin/main' into brennanb2025/agent-map-done-signal

# Conflicts:
#	src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts
This commit is contained in:
Brennan Benson
2026-08-13 01:17:15 -07:00
341 changed files with 20891 additions and 1950 deletions
+2 -1
View File
@@ -30,7 +30,8 @@ Fixes #
## AI Disclosure
<!-- DO NOT FILL IN IF !INTERNAL! CONTRIBUTOR: Which AI model if anyone was used, please state the details -->
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
<!-- Which AI model if anyone was used, please state the details -->
## Review
+22 -13
View File
@@ -126,7 +126,7 @@ jobs:
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
# Why: Electron on Linux needs an X display even when the app
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
@@ -174,7 +174,7 @@ jobs:
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
# launches but never creates a BrowserWindow.
- name: Run E2E tests (${{ matrix.shard_name }})
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 pnpm run test:e2e --shard=${{ matrix.shard }}
# Why: Playwright retains traces/screenshots only on failure. Uploading
# them as an artifact makes post-mortem debugging on CI possible without
@@ -202,7 +202,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -232,8 +232,15 @@ jobs:
env:
TEST_FILES_JSON: ${{ inputs.test_files }}
run: |
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
mapfile -t TEST_FILES < <(jq -r '.[] | select(
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
. != "tests/e2e/paired-startup-exec-readiness.spec.ts"
)' <<<"$TEST_FILES_JSON")
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
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 \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
- name: Upload Playwright traces
@@ -248,10 +255,13 @@ jobs:
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
if: inputs.test_files == ''
if: >-
inputs.test_files == '' ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
# Why 35: the parking, retention, startup-exec, and paired parity specs run
# serially on isolated Electron/SSH fixtures after watcher isolation.
timeout-minutes: 35
steps:
@@ -261,7 +271,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -295,10 +305,9 @@ jobs:
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why always(): these specs gate the C1 terminal parking/retention budget
# over a real SSH host and run in no other lane, so a watcher-isolation
# failure above must not silently skip them.
- name: Run Docker SSH terminal parking + retention E2E
# Why always(): this lane gates SSH parking/retention plus startup-exec
# readiness across live SSH, headed paired, and headless serve topologies.
- name: Run Docker SSH terminal parking + startup readiness E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
@@ -66,12 +66,14 @@ jobs:
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 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
- 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 test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
- name: Run golden E2E tests on Windows
if: runner.os == 'Windows'
+7 -3
View File
@@ -783,7 +783,7 @@ jobs:
{
echo "## Release E2E Signal"
echo ""
echo "- Platform golden E2E is release-blocking: terminal rendering on Linux/macOS and fresh startup on Windows."
echo "- Platform golden E2E is release-blocking: terminal rendering and restrictive-umask profile writes on Linux/macOS, plus fresh startup on Windows."
echo "- Full E2E runs separately after publication and cannot change the release result."
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
echo ""
@@ -871,11 +871,15 @@ jobs:
- 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 test:e2e:terminal-rendering-golden
run: |
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
- name: Run terminal rendering golden on macOS
if: runner.os == 'macOS'
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
run: |
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
- name: Run fresh-startup golden on Windows
if: runner.os == 'Windows'
+1
View File
@@ -104,6 +104,7 @@ docs/**
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/windows-setup-shell.md
!docs/reference/worktree-scan-fingerprint.md
# Stably CLI (only docs/ are tracked)
.stably/*
+351 -32
View File
@@ -21,18 +21,21 @@
"platforms": ["macos", "linux"],
"providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": ["local", "local-daemon", "ssh-relay"],
"coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity/readiness composition plus provider- and renderer-owned delivery. Renderer-owned relay fallback publishes the existing ready marker, so mixed client/host versions need no new wire capability. Live SSH, paired-runtime, and WSL remain gaps.",
"coveredProviders": ["local", "local-daemon", "ssh-relay", "paired-runtime"],
"coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity/readiness composition plus provider-owned delivery; explicit terminal-paste remains renderer-owned. Live isolated journeys cover a macOS headed desktop owner with paired web client, headless orca serve with paired web client, and a Docker Linux OpenSSH/Bash target with one readiness-time reconnect. Linux desktop, WSL/Windows, live zsh-over-SSH, and physical remote hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13767",
"https://linear.app/stably/issue/STA-4068"
"https://linear.app/stably/issue/STA-4067",
"https://linear.app/stably/issue/STA-4068",
"https://github.com/stablyai/orca/pull/14161"
],
"invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, prompt fallback releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger early fallback release.",
"invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, proof-based recovery releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger that proof. The existing bounded compatibility timeout may enqueue input without proof, but must not publish readiness, lose or duplicate that input across reconnect, or execute it before the replacement shell can accept it.",
"oracle": "Split Fish output immediately after the ready marker and after the following ESC introducer; local and daemon owners must emit the exact ESC [ ? 2004 h bytes and headless xterm must not render literal control text. Run the real generated wrappers under node-pty with disposable zsh, bash, and Fish startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must use the same lossless scanner composition and release through the same prompt contract.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready.test.ts src/main/providers/local-pty-provider.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/relay/pty-handler.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1"
],
"testFiles": [
"src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts",
@@ -47,7 +50,9 @@
"src/main/providers/local-pty-shell-ready.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/relay/pty-shell-launch.test.ts",
"src/relay/pty-handler.test.ts"
"src/relay/pty-handler.test.ts",
"tests/e2e/ssh-startup-exec-readiness.spec.ts",
"tests/e2e/paired-startup-exec-readiness.spec.ts"
],
"assertionRefs": [
{
@@ -88,6 +93,21 @@
"renderer-owned SSH delivery receives the existing ready marker after authoritative relay prompt readiness",
"normal renderer-owned ready markers pass through unchanged and retain no readiness state"
]
},
{
"file": "tests/e2e/ssh-startup-exec-readiness.spec.ts",
"assertions": [
"a Docker Linux OpenSSH/Bash replacement shell retains one compatibility-fallback input across reconnect without executing it before the blocked replacement can accept it",
"the owning terminal handle and raw PTY identity remain singular and stable",
"painted output, authoritative terminal output, and the first interactive input each appear exactly once"
]
},
{
"file": "tests/e2e/paired-startup-exec-readiness.spec.ts",
"assertions": [
"a headed desktop owner and paired client recover startup exec on one stable owning PTY",
"an isolated headless orca serve owner provides the same exact-once output and input contract"
]
}
],
"evidenceRuns": [
@@ -108,31 +128,50 @@
"result": "passed",
"durationSeconds": 6,
"summary": "473 tests passed with one local Fish skip, including every-boundary scanner composition, real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "linux",
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 17,
"summary": "The live OpenSSH/Bash relay journey crossed the bounded compatibility fallback while rc-file exec was blocked, proved one queued command echo with no command execution, then passed a disconnect/reconnect with exact-once startup output/input and stable pane plus owner PTY identities."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headless --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 14,
"summary": "The shared oracle passed a headed desktop owner plus paired web client and an isolated headless orca serve owner plus paired web client."
}
],
"runtimeBudget": {
"p95Seconds": 6,
"scope": "real local zsh/bash exec and silent-read integration gate"
"p95Seconds": 120,
"scope": "focused local wrapper contracts plus isolated SSH and paired live journeys"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Fresh deterministic local runs pass; CI and soak history have not started."
"evidence": "Fresh deterministic and isolated live runs pass locally; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The Fish chunk oracle patch SHA-256 fd49a4d3d91bd5f0d10c1b1f44f4cfbd92cef9155a5505b415c8680184cdc765 is byte-identical across runs. Scan target e4c278eb52, latest main 150cc6f50c, and candidate ae7f30318a with shared scanner integration disabled each failed only the ESC-introducer split in both local and daemon owners (2 failures, 161 passes). Candidate ae7f30318a passed all 163 local/daemon cases plus the independent headless-xterm render oracle. The original exec-readiness oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 remains 14/14 green."
"evidence": "Daemon oracle SHA-256 b13b08006a38b2adc2acb51ae9342909514e55ab4822fac4fbb76cd49b344cc6 was byte-identical across runs: affected baseline 09ec516ae5 failed 4 of 14, while pre-PR main bc2e30000b and this PR passed 14 of 14. The unchanged live SSH oracle failed on bc2e30000b and with the ownership change reverted because the ledger stayed pending after reconnect, then passed on this PR. Fish chunk oracle patch SHA-256 fd49a4d3d91bd5f0d10c1b1f44f4cfbd92cef9155a5505b415c8680184cdc765 was also byte-identical: scan target e4c278eb52, main 150cc6f50c, and candidate ae7f30318a with shared scanner integration disabled failed only the ESC-introducer split in local and daemon owners, while ae7f30318a passed all 163 cases plus headless-xterm rendering."
},
"performanceBudget": {
"required": true,
"evidence": "Two bounded linear scans partition identity ownership at the exact ready-marker boundary and retain only the existing bounded prefixes. The normal ready-marker path cancels before external probing. A missing marker triggers a debounced stty read only after a line-editor enable sequence, followed by narrow PID status and executable-image inspection only when termios matches. Slow ordinary startup output triggers zero probes; rejected protocol emissions are capped at four probes per startup. There is no per-command hook or steady-state polling."
},
"promotionCriteria": [
"Collect Linux, SSH-daemon, and paired-runtime live evidence.",
"Collect CI and soak history for the Docker SSH and paired live journeys.",
"Collect CI and soak history without unexplained flakes.",
"Keep same-shell, child-process, line-init-hook, and non-shell readline contracts green."
],
"knownGaps": [
"The fallback requires a readable POSIX PTY slave; WSL through wsl.exe, ConPTY, and native Windows retain the existing timeout backstop.",
"The live SSH journey covers Docker Linux OpenSSH with Bash, not zsh, WSL, Windows SSH, a Linux desktop renderer, or a physical remote host.",
"An exec integration that forks a replacement shell instead of retaining the identified shell PID retains the existing timeout backstop.",
"A zsh configuration that disables bracketed-paste output emits no safe post-hook protocol signal and retains the existing timeout backstop.",
"The pre-existing timeout backstop can still deliver queued input to a non-shell replacement after it expires; this fallback prevents indefinite startup hangs and is unchanged by the prompt fallback.",
@@ -1633,41 +1672,93 @@
"paired remote HTML file preview",
"background preview creation and explicit browser activation",
"client-only split placement",
"capability-unknown and mixed-version preview rejection cleanup",
"post-create reconciliation failure and compensating rollback",
"remote browser close convergence"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "A headed macOS desktop host and separate paired web client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, and browser close. Unit contracts cover owning-runtime routing, one-shot split placement, focused and unfocused creation, manual moves, overlapping opens, failure cleanup, and teardown. Linux, Windows, and a live headless-server journey remain gaps.",
"coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, and a forced post-create reconciliation timeout. The same fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server",
"https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr",
"https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909",
"https://github.com/stablyai/orca/pull/13876"
],
"invariant": "Opening one HTML preview from a paired client creates exactly one browser page on the worktree-owning runtime and exactly one mirrored client browser workspace. The preview remains in the requested client-side split without taking focus from the editor, an explicit browser click activates it authoritatively, and closing it removes only the browser identities while the source editor and terminal survive.",
"oracle": "Open a real HTML file through Explorer on a separate client paired to an isolated headed desktop host. Require one new host browser tab, one client browser workspace and unified tab, two visible split groups, an exact client-handle-to-host-page identity, rendered fixture DOM, editor focus after preview creation, host and client browser focus only after an explicit click, and browser-only close convergence with the editor and terminal still visible. Run the byte-identical Electron test on latest main, the candidate, and the candidate with the claimed fix disabled.",
"invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.",
"oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Repeat against headless serve. Retain the separate exact-page reconciliation rollback oracle.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
"SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
],
"testFiles": [
"src/main/runtime/orca-runtime-browser.test.ts",
"src/main/runtime/rpc/methods/browser.test.ts",
"src/renderer/src/lib/file-preview.test.ts",
"src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
"src/renderer/src/runtime/web-session-browser-placement.test.ts",
"src/renderer/src/runtime/web-runtime-browser-materialization.test.ts",
"src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"src/renderer/src/runtime/remote-server-parity.test.ts",
"tests/e2e/paired-remote-html-browser-focus.spec.ts"
"tests/e2e/paired-remote-html-browser-focus.spec.ts",
"tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts",
"assertions": [
"absent status, unknown capabilities, and mixed-version hosts preserve the unsupported rejection",
"direct creation removes the caller-declared new split without issuing an RPC"
]
},
{
"file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
"assertions": [
"known unavailability reports visibly without creating a split",
"capability loss after caller preflight reports visibly without duplicating transaction cleanup"
]
},
{
"file": "src/renderer/src/lib/file-preview.test.ts",
"assertions": [
"a failed side preview reports visibly without caller-owned split cleanup",
"overlapping side previews reuse the split without the first failure deleting it"
]
},
{
"file": "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts",
"assertions": [
"success requires the exact environment and remote page handle plus a unified browser tab",
"a requested client group must contain the materialized browser tab"
]
},
{
"file": "src/renderer/src/runtime/web-runtime-session.test.ts",
"assertions": [
"a post-create timeout returns false only after exact host close and a confirming snapshot",
"unconfirmed cleanup rejects so callers cannot create a competing local fallback",
"the canonical placement and a newly-created empty split are released on rollback",
"one failed preview cannot remove a shared split still reserved after an environment ownership change",
"if every overlapping creation fails, the last reservation closes the caller-created empty split"
]
},
{
"file": "src/renderer/src/runtime/web-session-browser-placement.test.ts",
"assertions": [
"pending page placements are bounded and independently reserve shared groups",
"pending page placements are bounded and reserve worktree groups across environment ownership changes",
"caller-created cleanup transfers to the last failed overlapping reservation",
"capacity rejects a new placement without evicting an active reservation or its cleanup ownership",
"worktree and environment teardown clear only their own placement state"
]
},
@@ -1692,9 +1783,81 @@
"the source editor and remote frame remain visible in separate groups",
"preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal"
]
},
{
"file": "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts",
"assertions": [
"the exact created page exists on the host while publication is held and no browser tab materializes on the client",
"a forced post-create reconciliation timeout closes the exact host page and removes only the reserved empty split",
"a forced capability rejection after caller preflight shows a toast and restores exact host and client inventories",
"headed and headless hosts preserve the unrelated editor and terminal without local fallback"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
"result": "failed",
"durationSeconds": 0.72,
"summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts",
"result": "passed",
"durationSeconds": 0.7,
"summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 18.3,
"summary": "The full headed spec passed 2/2. A visible desktop host and separate paired Electron client proved both reconciliation rollback and capability rejection with a failure toast and exact host/client browser, group, editor, and terminal inventory restoration."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 9.1,
"summary": "The full isolated headless-serve journey passed 1/1, applying both reconciliation and capability faults to the same live host while preserving the visible-toast and exact inventory-restoration oracle."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts",
"result": "passed",
"durationSeconds": 0.11,
"summary": "The exact environment, remote page, client workspace, unified browser tab, and requested group materialization contract passed 1/1 on the final rebased candidate."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 24.3,
"summary": "A visible desktop host and separate paired Electron client held one exact host page before client materialization, forced the post-create timeout, then proved exact host rollback, empty-split removal, failure UI, no local fallback, and surviving editor and terminal."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 18.5,
"summary": "The same exact-page hold, timeout, rollback, client cleanup, no-fallback, editor, and terminal oracle passed against isolated headless serve."
},
{
"date": "2026-08-12",
"runner": "local",
@@ -1778,8 +1941,8 @@
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "seven focused host/renderer contracts plus one isolated headed paired-runtime journey"
"p95Seconds": 90,
"scope": "focused host/renderer contracts plus normal and faulted headed/headless paired-runtime journeys"
},
"flakeHistory": {
"status": "unknown",
@@ -1787,20 +1950,19 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical 2ec1dcf1 Electron oracle is red on latest main 346e59c879, green on candidate d05217c40d, and red when only the candidate's side-preview focusOnCreate:false call is disabled. Latest main created two client browser workspaces; the disabled candidate focused the browser before an explicit click. Historical 5806b23f evidence separately proves the owning-runtime routing seam."
"evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing."
},
"performanceBudget": {
"required": true,
"evidence": "The change adds no timer, polling, retry, subprocess, RPC method, stream opcode, receipt ledger, or host publication. Side previews install no focus listener and record no focus intent. Focused browser creation performs one bounded scan of the current worktree's local tabs. Pending client placements are capped at 128 and cleared on adoption, failure, or worktree/environment teardown."
"evidence": "The transaction adds no timer, polling, retry, subprocess, RPC method, stream opcode, or host publication. Capability rejection performs zero RPCs and one bounded empty-group close. Successful creation adds one post-operation list; known-page failure adds one exact close and one confirming list. The E2E-only one-shot faults are build-gated, retain one promise/flag and a bounded page-id set, and reset after each run. Pending client placements remain capped at 128 and clear on adoption, failure, or teardown."
},
"promotionCriteria": [
"Collect Linux and physical Windows paired-server evidence.",
"Collect a live headless-serve HTML preview journey.",
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake."
],
"knownGaps": [
"Live paired-server validation currently covers macOS only.",
"A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.",
"Multiple simultaneous viewers are not exercised by the Electron journey.",
"Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path."
],
"demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close."
@@ -3130,6 +3292,130 @@
],
"demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug."
},
{
"id": "agent-launch.discovery-authority",
"title": "Agent discovery and launch use the same workspace authority",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session",
"layer": "renderer-store-contract",
"surfaces": ["Quick Launch agent inventory", "agent launch", "Floating Workspace"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "wsl", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos", "windows"],
"coveredProviders": ["local", "wsl"],
"coverageNotes": "A deterministic Windows-mode renderer oracle keeps an active WSL project and explicit-local Floating Workspace mounted together with disjoint agent inventories, then independently asserts discovery and launch authority. A headed physical Windows 2 journey with real Ubuntu WSL2 independently verified the rendered inventory and native PowerShell launch; SSH and paired-runtime behavior retain their existing contract coverage.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-4027/p1-floating-workspace-agent-detection-still-uses-active-wsl-project",
"https://github.com/stablyai/orca/pull/13995"
],
"invariant": "Every agent launch surface must advertise the provider set detected on its explicit execution authority and launch on that same authority; a local Floating Workspace must never inherit an unrelated active project's WSL, SSH, or paired-runtime context.",
"oracle": "Model an active WSL project and a native-Windows Floating Workspace with WSL-only Claude and host-only Codex. Keep both detection consumers mounted, require their advertised sets to stay disjoint through refresh, require the legacy active-project inventory to remain unchanged, then independently assert the Floating launch call and PTY transport select local IPC with no WSL project runtime.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot",
"Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace"
],
"testFiles": [
"src/renderer/src/hooks/useDetectedAgents.test.tsx",
"src/renderer/src/hooks/useAgentDetectionTarget.test.ts",
"src/renderer/src/store/slices/detected-agents.test.ts",
"src/renderer/src/store/slices/local-detected-agent-state.test.ts",
"src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts",
"src/renderer/src/lib/local-preflight-context.test.ts",
"src/renderer/src/lib/launch-agent-in-new-tab.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/hooks/useDetectedAgents.test.tsx",
"assertions": [
"the active WSL project advertises only its WSL agent while Floating advertises only the native-host agent",
"Floating refresh preserves both scoped inventories and does not overwrite the legacy active-project inventory"
]
},
{
"file": "src/renderer/src/store/slices/local-detected-agent-state.test.ts",
"assertions": [
"same-context callers deduplicate without cached broadcast fanout",
"refresh supersedes stale detection and failed cold refresh remains retryable",
"project removal evicts settled and in-flight context state"
]
},
{
"file": "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts",
"assertions": [
"removing one project evicts its authority-scoped agent inventory without disturbing another project"
]
},
{
"file": "src/renderer/src/lib/launch-agent-in-new-tab.test.ts",
"assertions": [
"the advertised Floating agent launches in a Floating tab with null runtime ownership and no paired-runtime create call"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"the Floating agent's actual PTY selects local IPC, carries executionHostId local, and omits the active project's WSL runtime"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose",
"result": "passed",
"durationSeconds": 2,
"summary": "Seven focused files passed independent WSL/local inventory, refresh isolation, cache lifecycle, project eviction, and Floating launch-call assertions."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 20,
"summary": "The full PTY connection suite passed the independent Floating native-host transport oracle."
},
{
"date": "2026-08-12",
"runner": "manual",
"platform": "windows",
"command": "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace",
"result": "passed",
"durationSeconds": 22.3,
"summary": "Environment abfee683-80eb-4ac3-ada0-da5d1b6303a6, runtime b3bea13e-f6b7-41e7-b850-0a118f7eda38: Floating advertised native Claude/Codex without WSL-only Gemini and launched Claude through native PowerShell with local host ownership while the WSL project remained active."
}
],
"runtimeBudget": {
"p95Seconds": 40,
"scope": "eight focused renderer authority, cache, eviction, and launch test files"
},
"flakeHistory": {
"status": "soaking",
"evidence": "The deterministic oracle uses mocked provider results and React effect barriers. Exact-head PR run 31644438349 passed the full Node 24/26 matrix; an earlier patch-identical run's transient shard failures passed on rerun, but long-term soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical oracle fails on merged PR #13995 and the tested latest-main snapshot ebe5125476 because Floating receives the WSL-only Claude inventory while launch remains local. The candidate passes; disabling the scoped Floating target reproduces the mismatch."
},
"performanceBudget": {
"required": true,
"evidence": "Floating uses one stable host context key, retained tab bars do not rescan worktree indexes on store writes, concurrent probes deduplicate per context, and in-flight promise entries are deleted on settlement."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Keep the physical Windows/WSL inventory and launch journey green when authority routing changes.",
"Keep disjoint provider inventories, refresh isolation, launch authority, and bounded in-flight state green."
],
"knownGaps": [
"Live SSH and paired-runtime launch journeys are covered by existing authority contracts rather than this focused oracle."
],
"demotionRule": "Demote if discovery and launch can resolve different authorities, a scoped Floating probe overwrites the active-project inventory, or local context selection adds recurring scans or retained in-flight entries."
},
{
"id": "agent-session.provider-ownership",
"title": "Provider sessions are resumed once per workspace ownership claim",
@@ -3670,17 +3956,22 @@
"providers": ["local", "remote-runtime", "ssh", "wsl"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "remote-runtime", "ssh", "wsl"],
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project owner capture and stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus a separate paired Electron client proves real Git, plain-folder, Clone, Create, nested import, runtime-switch, disconnect, reconnect, concurrently visible same-ID local/runtime repo/worktree/group/folder rows, reversed catalog order, reversed refresh completion, local-after-runtime catalog refresh routing, and first paired-terminal activation without local fallback; hidden-window parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/9687"],
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project scan, add, import, catalog refresh, and worktree refresh must stay on the host selected when that operation began, even if the durable preference changes or the repo catalog is not hydrated. Generic settings mutation cannot bypass the dedicated preference IPC.",
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. In a separate paired Electron client, select a non-default headed runtime and add a real Git repo, plain folder, clone, created project, and nested repos while switching the preference and disconnecting/reconnecting. Before Git Add, plain-folder Add, Clone, and Create completion, insert a same-ID local repo/worktree decoy; require final activation to remain on the captured runtime. Merge same-ID local/runtime repo, worktree, group, and folder rows, reverse same-host refresh completions, overlap refresh with reconnect, issue ordinary unqualified local refreshes, then create the first paired runtime terminal while the same-ID local/runtime worktrees remain visible. Require only the selected server inventory to change, exact runtime ownership in the client store, preserved host-qualified catalogs, authoritative direct-SSH pruning without runtime sibling loss, visible projects, and zero direct SSH fallback. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project eligibility/owner capture/stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus separate paired Electron and web clients prove selected-runtime Add Project operations and disabled creation when the only listed host is blocked or unavailable; headless-server parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/9687",
"https://linear.app/stably/issue/STA-4024/p1-paired-web-add-project-enables-unavailable-host-fallback-after-pr"
],
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project selected host must satisfy canSelectAddRepoHost; paired web has no local filesystem fallback, so no valid host means Browse, Clone, and Create remain disabled. Every operation stays on the host selected when it began, even if preference or catalog state changes. Generic settings mutation cannot bypass the dedicated preference IPC.",
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat transient routing and selected-runtime Add Project operations with exact-ID host decoys and completion reordering. Separately pair the real web client to an isolated headed host, then make its sole still-listed runtime compatibility-blocked and unavailable. Require a null authoritative selection, a disabled host option, and disabled Browse, Clone, and Create actions with no local fallback. Repeat against headless serve. Run the byte-identical selector/action contract on latest main, candidate, and candidate with the eligibility seam disabled.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/runtime/runtime-server-directory-browser.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
"SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
],
"testFiles": [
"src/main/ipc/settings.test.ts",
@@ -3717,13 +4008,15 @@
"src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts",
"src/renderer/src/store/slices/worktrees.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"tests/e2e/pr11346-selected-runtime-add.spec.ts"
"tests/e2e/pr11346-selected-runtime-add.spec.ts",
"tests/e2e/paired-web-add-project-unavailable-host.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
"assertions": [
"paired web hides the viewing computer as an Add Project filesystem authority and selects the paired runtime"
"paired web hides the viewing computer as an Add Project filesystem authority and selects only an eligible paired runtime",
"a sole blocked or errored runtime remains listed but produces no selected Add Project host"
]
},
{
@@ -3855,9 +4148,35 @@
"keeps every expanded selected-runtime Add Project path in hidden-window desktop parity",
"creates the first paired runtime terminal while same-ID local/runtime worktrees remain visible without clearing the selected runtime owner"
]
},
{
"file": "tests/e2e/paired-web-add-project-unavailable-host.spec.ts",
"assertions": [
"a version-blocked sole host stays visible and non-selectable while Browse, Clone, and Create are disabled",
"an unavailable sole host has the same disabled action contract without a local fallback",
"the rendered contract passes against headed desktop and headless serve hosts"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 12.3,
"summary": "A real paired web client against a visible desktop host kept the sole blocked and unavailable runtime listed but non-selectable, with Browse, Clone, and Create disabled and no local host fallback."
},
{
"date": "2026-08-12",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 5.8,
"summary": "The same present-but-blocked and present-but-unavailable Add Project action oracle passed against isolated headless serve."
},
{
"date": "2026-07-22",
"runner": "local",
@@ -3932,7 +4251,7 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact."
"evidence": "The Add Project selector file from fix-disabled overlay 17a612c819 and candidate 8abf0f5d20 is byte-identical (SHA-256 f34c3c69c8adba23811c3a3b6a0efb669f787666c5ada086df8a167e785eea20): its blocked and unavailable sole-host cases fail with the production fix disabled on latest main ebe5125476 after PR #13909 and pass on the candidate. The focused combined selector/transaction invocation was 3 failed with 69 skipped on the red overlay and 3 passed with 75 skipped on the candidate; the different totals come from supplemental browser-transaction cases, not this selector file. The originating/latest affected main 1136503c6a and its fix-disabled overlay were also red before the provider-gating rebase. The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact."
},
"performanceBudget": {
"required": true,
@@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => {
'build:linux',
'test:e2e',
'test:e2e:terminal-rendering-golden',
'test:e2e:posix-profile-index-golden',
'test:e2e:terminal-rendering-release-evidence',
'test:e2e:headful'
]
@@ -543,6 +544,15 @@ describe('Electron runtime package contract', () => {
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
'@windows-fresh-startup-golden'
)
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
'golden-posix-profile-index-fsync.spec.ts'
)
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
'golden-posix-fresh-startup.spec.ts'
)
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
'@posix-profile-index-golden'
)
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
'terminal-opencode-emoji-table-rendering.spec.ts'
)
@@ -557,7 +567,11 @@ describe('Electron runtime package contract', () => {
expect(goldenRunSteps.get('linux')?.run).toContain(
'pnpm run test:e2e:terminal-rendering-golden'
)
expect(goldenRunSteps.get('linux')?.run).toContain(
'pnpm run 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('windows')).toMatchObject({
if: "runner.os == 'Windows'",
shell: 'pwsh'
@@ -574,9 +588,16 @@ describe('Electron runtime package contract', () => {
expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence')
expect(releaseGoldenJob['continue-on-error']).toBeUndefined()
expect(releaseGoldenMatrix).toEqual(goldenMatrix)
expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain(
'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden'
const releaseLinuxRunStep = releaseGoldenJob.steps.find(
(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')
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')
const releaseWindowsRunStep = releaseGoldenJob.steps.find(
(step) => step.name === 'Run fresh-startup golden on Windows'
)
+27 -1
View File
@@ -6,6 +6,10 @@ import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
const sshDockerRunner = readFileSync(
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
'utf8'
)
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter changed E2E specs'
@@ -64,14 +68,36 @@ describe('PR E2E gate contract', () => {
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''")
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
(step) => step.name === 'Run changed E2E specs'
)
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
})
it('keeps startup-exec live parity in the isolated SSH lane', () => {
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
expect(sshLaneCondition).toContain("inputs.test_files == ''")
expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain("'electron-headless'")
expect(sshDockerRunner).toContain("'electron-headful'")
})
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
step.name.startsWith('Install native build')
)
expect(installStep.run, jobName).toMatch(/\bzsh\b/)
}
})
it('keeps dedicated E2E workflows out of pull request CI', () => {
const dedicatedWorkflows = [
'golden-e2e-experiment.yml',
@@ -5,7 +5,8 @@ const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArg
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
@@ -21,9 +22,8 @@ if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
// Why both specs in one runner: they share the docker SSH rig and the same
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
// restore, the retention spec proves the C1 budget bounds what they retain.
// Why one runner: these specs share the Docker SSH rig; startup-exec also joins
// the paired desktop/headless parity proof under the same isolated CI lane.
const result = spawnSync(
pnpm,
[
@@ -32,10 +32,14 @@ const result = spawnSync(
'test',
'tests/e2e/ssh-terminal-parking.spec.ts',
'tests/e2e/terminal-retention-budget.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/paired-startup-exec-readiness.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--project',
'electron-headful',
'--workers=1',
...extraArgs
],
+274
View File
@@ -0,0 +1,274 @@
# Steady-state worktree rescan: Git-admin fingerprint gate
## Status
Adopted for the main-process worktree resolution cache
(`OrcaRuntimeService.listRepoWorktreesForResolution`). It keeps the existing
30-second freshness contract for externally created, removed, moved, locked, and
re-checked-out worktrees while removing the `git worktree list` subprocess that
previously ran for every registered repository every 30 seconds.
## Context
A production trace (10 registered repositories, 3 h 27 min) recorded 4,272
`git worktree` invocations and 8,663 total Git subprocesses. The invocations
arrived in full-fleet sweeps roughly every 30.5 s — one sweep per repository per
`WORKTREE_SCAN_CACHE_TTL_MS`.
### What actually expires the cache
The originating report assumed some specific terminal/status/orchestration
request was invalidating the 30-second cache. That is not what happens, and the
distinction changes the fix:
- `resolvedWorktreeCache` (the whole-fleet snapshot) has a **1-second** TTL
(`RESOLVED_WORKTREE_CACHE_TTL_MS`). Any caller polling faster than 1 Hz
recomputes the snapshot.
- `computeResolvedWorktrees` fans out over **every** registered repo
(`src/main/runtime/orca-runtime.ts`), calling
`listRepoWorktreesForResolution` per repo.
- That per-repo call is backed by `worktreeScanCache` with a **30-second** TTL.
When it expires, the next poll shells out.
So no request "expires" the 30-second cache. It expires on wall-clock time, and
whichever poller arrives first afterwards pays a full-fleet `git worktree list`
fan-out. The many high-frequency callers — `listTerminals` without a selector,
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
`resolveWorktreeSelector`, orchestration authority refresh — only determine
*who* pays, not *how often*. Steady-state subprocess volume is therefore
`repos / 30 s`, independent of poll rate, which is exactly the observed
~1 sweep / 30.5 s.
The in-Orca mutation surface is already event-driven: create, remove, rename,
folder-rename, sparse edits, repo add/update/remove, SSH reconnect, and
mixed-version remote invalidation all call
`invalidateWorktreeScanCacheForRepo` / `invalidateResolvedWorktreeCache`
(≈40 call sites). The 30-second TTL exists for exactly one reason: discovering
worktree changes made **outside** Orca (`git worktree add/remove/move/prune`,
`git checkout` in another worktree, `rm -rf` of a worktree directory).
That is a filesystem question, and the filesystem can answer it without a
subprocess.
## Goals
- Remove the periodic all-repository `git worktree list` fan-out in steady
state.
- Preserve the current ≈30 s discovery latency for externally created, removed,
moved, pruned, locked/unlocked, and re-checked-out worktrees.
- Keep a bounded reconciliation so anything the cheap probe cannot observe still
converges.
- Change nothing for SSH repos, WSL-routed repos, folder workspaces, bare repos,
or hosts where the probe cannot resolve Git's admin layout.
- Fail open: any probe error must behave exactly like today (run the real scan).
## Non-goals
- Changing `RESOLVED_WORKTREE_CACHE_TTL_MS` or the whole-fleet snapshot shape.
- Changing the renderer-facing `worktrees:list` / `worktrees:listAll` IPC scan
cache in `src/main/ipc/worktrees.ts` (separate 5 s cache, invalidated by
`registerWorktreeChangeInvalidator`, not a polling source in the trace).
- Scoping `resolveWorktreeSelector` to a single repository. See
"Rejected alternatives".
- Adding a filesystem watcher per repository.
- Any wire/RPC/persisted-schema change.
## Design
Introduce a cheap, subprocess-free **Git worktree admin fingerprint** for a
local repository, and consult it before re-running a scan whose TTL has expired.
### Fingerprint inputs
`readRepoWorktreeAdminFingerprint(repoPath)` in
`src/main/runtime/repo-worktree-admin-fingerprint.ts` resolves the repo's Git
common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
bare gitdir), then records:
| Input | External change it catches |
| --- | --- |
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
| existence of `repoPath` | main checkout deleted |
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
| per entry: `locked` presence | `worktree lock` / `unlock` |
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
"per checkout" covers the main worktree and each linked worktree. Reading the
ref HEAD names is what makes an ordinary commit visible: committing rewrites
`refs/heads/<branch>` and leaves `HEAD` untouched, but moves the oid
`git worktree list --porcelain` prints. A symref target is only followed when it
is a relative path under `refs/`, so a hand-edited `HEAD` cannot steer the probe
outside the ref store.
Every input is a `stat`, `readdir`, or small `readFile` on an already-hot inode,
and the fingerprint depends on nothing but the repo path — no prior scan result
is threaded in. Per-repo fan-out is capped at 8 concurrent linked-worktree
probes, mirroring `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`. A 10-repo fleet with
10 worktrees each costs a few hundred filesystem calls per 30 s window versus 10
process spawns; the spawns dominate by orders of magnitude, and process-table
churn was the reported symptom.
The fingerprint is a NUL-delimited string; any read failure yields `null`, which
the caller treats as "cannot prove unchanged".
### Cache decision
`listRepoWorktreesForResolution` gains one branch on the expired-TTL path:
```text
cached entry exists, same generation + runtimeKey, TTL expired
└─ probe eligible? (no connectionId, no wslDistro, fingerprint recorded)
├─ no → real scan (today's behaviour)
└─ yes → read fingerprint now
├─ null or different → real scan
├─ equal, last real scan < 5 min → extend TTL, no subprocess
└─ equal, last real scan ≥ 5 min → real scan (bounded reconcile)
```
`WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS` is 5 min, matching the existing
`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS` precedent. A cached result whose scan
failed (`ok: false`) is never extended, so a transient Git failure still retries
on the 30 s TTL.
### Git version compatibility
Every path the probe reads is part of Git's on-disk layout well before the 2.25
baseline in [`git-compatibility.md`](./git-compatibility.md): `.git` as a
directory or `gitdir:` file, `commondir`, `worktrees/<name>/{HEAD,gitdir,locked}`,
`packed-refs`, and loose `refs/`. `reftable` arrived in 2.45; on older Git it
simply stats as missing, which is a stable value and therefore harmless. No new
Git command is introduced — this change only skips one.
Agent-scratch repos already carry a 5-minute scan TTL
(`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS`), which equals the reconciliation
interval, so the gate never fires for them and their behaviour is unchanged.
### Ordering
The fingerprint stored with a scan result is captured **before** the scan runs.
A mutation landing while the scan is in flight therefore leaves the stored
fingerprint stale-by-construction, so the next probe sees a difference and
rescans. Capturing after the scan would let that mutation be masked forever.
### Interaction with existing invalidation
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
included) and bumps the generation, so every event-driven path still forces a
real scan on the next read. The fingerprint only ever *extends* an entry that
the TTL alone would have refreshed.
## Freshness budget
| Change | Before | After |
| --- | --- | --- |
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
The two regressions are bounded by the reconciliation interval and are both
changes Orca does not make itself.
### Main-thread cost
Both the scan and the probe are asynchronous, so neither "runs on the main
thread" in the naive sense — but they are not equally free there. Measured on
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
against a repo with 20 linked worktrees:
| | wall per call | main-thread stall per call | worst single stall |
| --- | --- | --- | --- |
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
collection and decoding are real synchronous main-process work. Ten repos
refreshing together therefore cost ≈27 ms of event-loop stall per sweep before
this change and ≈0.1 ms after — this reduces main-thread pressure rather than
adding to it, which is why moving either side onto a worker thread would not
help.
### Residual risk
A repo registered at a UNC path (`\\wsl$\...`) but executed by the local Windows
Git runtime is still probed, because it is not WSL-routed from Orca's point of
view. The probe is correct there and strictly cheaper than the subprocess it
replaces, but its filesystem calls cross the 9p boundary like the existing
sparse-checkout probes already do.
## Measured effect
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts` drives the
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
simulated minutes — and counts `git worktree list` invocations:
| | `git worktree list` per 30 min | per hour |
| --- | --- | --- |
| TTL only (before) | 600 | 1,200 |
| fingerprint gate (after) | 60 | 120 |
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
with genuine external activity keep rescanning at the 30 s cadence because the
fingerprint flips.
Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
`git worktree` invocations would become ≈427.
## Rejected alternatives
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
reduction, but it degrades *every* external-change latency to 5 min, including
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
the same reduction without that regression.
**Per-repo `fs.watch` on `<commonDir>/worktrees`.** Lower latency, but adds
persistent watcher handles per repo, inherits recursive-watch platform
differences, and would need its own dormancy/rearm story. The existing watcher
infrastructure is scoped to workspace files; extending it to Git admin dirs is a
larger change with a worse risk profile for the same steady-state win.
**Scope `resolveWorktreeSelector` to the owning repository.** The original brief
asks for this. `listTerminals` already avoids the fan-out for explicit worktree
ids via `buildResolvedWorktreeFromId` +
`listKnownResolvedWorktreesForExplicitTarget`. Extending that to
`resolveWorktreeSelector` means splitting the highest-fan-in method in the
runtime (38 call sites) and rebuilding lineage projection for a repo subset —
material regression risk. Once the fingerprint gate lands, the remaining
fan-out cost for a targeted call is a batch of stats, not a subprocess, so the
gain no longer justifies the risk. Tracked as follow-up, not in this change.
## Test plan
`src/main/runtime/repo-worktree-admin-fingerprint.test.ts` (real temp dirs, real
`git` binary — a mocked filesystem would only restate the assumptions):
- stable across repeated reads with no change
- changes after `worktree add`, `worktree remove`, `worktree move`,
`worktree lock`, `checkout` in a linked worktree, `checkout` in the main
worktree, a commit in either, and `rm -rf` of a worktree directory
- still tracks a tip whose loose ref has been packed away by `git pack-refs`
- a linked worktree path and the main repo path produce the same fingerprint
- a bare repo resolves through its own gitdir and still tracks `worktree add`
- `null` for a non-Git directory and for a missing path
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts`:
- unchanged fingerprint suppresses the rescan past the 30 s TTL and re-arms it
- changed fingerprint rescans at the 30 s TTL
- the 5-minute reconciliation forces a rescan while the fingerprint is unchanged
- `notifyBranchRenamed` (event invalidation) still forces an immediate rescan
- a `null` fingerprint (probe failure) falls back to scanning
- SSH repos never consult the probe
- a failed scan is never extended
- concurrent callers share one probe and one scan
- the 1 Hz / 10-repo workload measurement above
@@ -10,6 +10,11 @@ export default function MobileFilePreviewRoute() {
source?: string | string[]
absolutePath?: string | string[]
grantId?: string | string[]
terminal?: string | string[]
pathText?: string | string[]
cwd?: string | string[]
nativeChatTab?: string | string[]
nativeChatSession?: string | string[]
line?: string | string[]
column?: string | string[]
name?: string | string[]
+55 -17
View File
@@ -127,6 +127,15 @@ 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 {
@@ -917,6 +926,9 @@ export default function SessionScreen() {
} | null>(null)
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
// Why: a lease-only subscribe never renders, so the reconciler needs to tell it apart
// from a stream that does — an uncovered handle holding one is a blank terminal.
const leaseOnlyHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
@@ -1233,6 +1245,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
leaseOnlyHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
@@ -1265,6 +1278,7 @@ export default function SessionScreen() {
clearNativeChatInputLease()
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
leaseOnlyHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
viewportResubscribeBudgetRef.current.clear()
@@ -1331,6 +1345,11 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.add(handle)
if (covered) {
leaseOnlyHandlesRef.current.add(handle)
} else {
leaseOnlyHandlesRef.current.delete(handle)
}
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.current)
@@ -1519,6 +1538,7 @@ export default function SessionScreen() {
streamRevision: coveredStreamRevision,
subscriptionsRef: terminalUnsubsRef,
subscribingRef: subscribingHandlesRef,
leaseOnlyRef: leaseOnlyHandlesRef,
webReadyRef: webReadyHandlesRef,
initializedRef: initializedHandlesRef,
subscribe: subscribeToTerminal,
@@ -3085,6 +3105,10 @@ export default function SessionScreen() {
hostId,
worktreeId,
worktreeName: routeWorktreeName,
nativeChatSessionId:
activeSessionTab?.type === 'terminal'
? (activeSessionTab.agentStatus?.providerSession?.id ?? null)
: null,
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
@@ -3650,20 +3674,27 @@ export default function SessionScreen() {
.slice(2, 10)}`
try {
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'
})
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 }
)
if (response.ok) {
const result = (response as RpcSuccess).result as TerminalCreateResult
const created = result.tab
@@ -3763,10 +3794,17 @@ export default function SessionScreen() {
showToast(message, 1800)
}
}
} catch {
const message = options?.errorToast ?? 'Failed to create terminal'
} 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')
setCreateError(message)
if (options?.errorToast) {
if (
options?.errorToast ||
errorMessage === MOBILE_TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
) {
triggerError()
showToast(message, 1800)
}
@@ -3,6 +3,8 @@
// viewer). We deliberately favor precision over recall: a missed path is a minor
// annoyance, but a false positive on prose or a version number is a broken tap.
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
export type FilePathSegment =
| { type: 'text'; value: string }
| { type: 'file'; value: string; path: string }
@@ -101,8 +103,6 @@ function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
/**
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
@@ -112,14 +112,14 @@ export function splitFilePathLineSuffix(pathText: string): {
line: number | null
column: number | null
} {
const match = LINE_SUFFIX_PATTERN.exec(pathText)
if (!match) {
const parsed = parseFileLinkLocation(pathText)
if (!parsed || (parsed.line === null && parsed.column === null)) {
return { path: pathText, line: null, column: null }
}
return {
path: match[1]!,
line: Number.parseInt(match[2]!, 10),
column: match[3] ? Number.parseInt(match[3], 10) : null
path: parsed.pathText,
line: parsed.line,
column: parsed.column
}
}
@@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => {
).toBe(false)
})
it('treats native-chat artifact grants as read-only', () => {
expect(
isEditableMobileTerminalArtifactPreview(
{
status: 'ready',
kind: 'text',
content: '<h1>Result</h1>',
truncated: false,
byteLength: 15
},
true
)
).toBe(false)
})
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
const sourceKey = sourceKeyForPreview({
source: 'terminalArtifact',
+2 -1
View File
@@ -181,7 +181,8 @@ export function MobileFilePreviewScreen({ route }: Props) {
)
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
const isEditableTerminalArtifact =
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
previewSource?.source === 'terminalArtifact' &&
isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true)
const canSaveArtifact =
isEditableTerminalArtifact &&
draftSourceKeyRef.current === previewSourceKey &&
@@ -3,7 +3,13 @@ import type {
MobileFilePreviewSource
} from './mobile-file-preview-request'
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
export function isEditableMobileTerminalArtifactPreview(
preview: MobileFilePreviewResult,
readOnly = false
): boolean {
if (readOnly) {
return false
}
return (
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
preview.status === 'empty'
@@ -179,6 +179,47 @@ describe('mobile-file-preview-request', () => {
})
})
it('refreshes a native-chat artifact grant with the same transcript provenance', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_expired'),
ok({
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-2',
readOnly: true
}
}),
ok({ content: '<h1>Result</h1>', truncated: false, byteLength: 15 })
])
const onTerminalArtifactSourceRefreshed = vi.fn()
await loadMobileFilePreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
pathText: '~/orca-plans/result.html',
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
},
undefined,
{ onTerminalArtifactSourceRefreshed }
)
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
worktree: 'id:wt-1',
pathText: '~/orca-plans/result.html',
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
})
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith(
expect.objectContaining({ grantId: 'grant-2', readOnly: true })
)
})
it.each([
['terminal_file_grant_expired'],
['terminal_file_grant_mismatch'],
@@ -242,6 +283,25 @@ describe('mobile-file-preview-request', () => {
})
})
it('does not send writes for read-only native-chat artifacts', async () => {
const client = clientWith(ok({ ok: true }))
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
readOnly: true
},
'<h1>Changed</h1>'
)
).resolves.toMatchObject({ status: 'error' })
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
@@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview(
content: string,
options: TerminalArtifactSaveOptions = {}
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
if (source.readOnly) {
return previewError('This file is read-only')
}
let writeSource = source
if (typeof options.baseContent === 'string') {
const verified = await verifyTerminalArtifactBaseContent(
@@ -65,6 +65,8 @@ describe('mobile-file-preview-route', () => {
terminal: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1',
line: '12',
column: '3'
})
@@ -80,6 +82,8 @@ describe('mobile-file-preview-route', () => {
terminal: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1',
line: '12',
column: '3'
}
@@ -10,6 +10,8 @@ export type MobileFilePreviewRouteParams = {
terminal?: string
pathText?: string
cwd?: string
nativeChatTab?: string
nativeChatSession?: string
line?: string
column?: string
name?: string
@@ -35,6 +37,8 @@ type RawPreviewRouteParams = {
terminal?: MobileFilePreviewParamValue
pathText?: MobileFilePreviewParamValue
cwd?: MobileFilePreviewParamValue
nativeChatTab?: MobileFilePreviewParamValue
nativeChatSession?: MobileFilePreviewParamValue
line?: MobileFilePreviewParamValue
column?: MobileFilePreviewParamValue
name?: MobileFilePreviewParamValue
@@ -76,6 +80,8 @@ export function normalizeMobileFilePreviewRouteParams(
terminal: optionalSingleParam(params.terminal),
pathText: optionalSingleParam(params.pathText),
cwd: optionalSingleParam(params.cwd),
nativeChatTab: optionalSingleParam(params.nativeChatTab),
nativeChatSession: optionalSingleParam(params.nativeChatSession),
line: optionalSingleParam(params.line),
column: optionalSingleParam(params.column),
name: optionalSingleParam(params.name),
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { sourceKeyForPreview } from './mobile-file-preview-source'
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
describe('mobile-file-preview-source', () => {
it('uses structured preview keys so colons in paths cannot collide', () => {
@@ -25,4 +25,21 @@ describe('mobile-file-preview-source', () => {
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
})
it('makes native-chat artifact previews read-only', () => {
expect(
previewSourceFromRoute({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath: '/tmp/result.html',
grantId: 'grant-1',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1'
})
).toMatchObject({
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' },
readOnly: true
})
})
})
+18 -9
View File
@@ -15,7 +15,16 @@ export function previewSourceFromRoute(
grantId: params.grantId,
...(params.terminal ? { terminalHandle: params.terminal } : {}),
...(params.pathText ? { pathText: params.pathText } : {}),
...(params.cwd ? { cwd: params.cwd } : {})
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.nativeChatTab && params.nativeChatSession
? {
nativeChatContext: {
tabId: params.nativeChatTab,
sessionId: params.nativeChatSession
},
readOnly: true as const
}
: {})
}
}
if (!params.relativePath) {
@@ -28,12 +37,12 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str
if (!source) {
return null
}
return source.source === 'terminalArtifact'
? JSON.stringify([
'terminal',
source.worktreeId,
source.absolutePath,
source.terminalHandle ?? ''
])
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
if (source.source !== 'terminalArtifact') {
return JSON.stringify(['worktree', source.worktreeId, source.relativePath])
}
const key = ['terminal', source.worktreeId, source.absolutePath, source.terminalHandle ?? '']
if (source.nativeChatContext) {
key.push(source.nativeChatContext.tabId, source.nativeChatContext.sessionId)
}
return JSON.stringify(key)
}
@@ -1,3 +1,4 @@
import type { RuntimeNativeChatFileContext } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
@@ -12,6 +13,8 @@ export type MobileTerminalArtifactPreviewSource = {
terminalHandle?: string
pathText?: string
cwd?: string
nativeChatContext?: RuntimeNativeChatFileContext
readOnly?: true
}
export type TerminalArtifactRetryOptions = {
@@ -32,7 +35,8 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure(
worktree: `id:${source.worktreeId}`,
pathText: source.pathText ?? source.absolutePath,
...(source.cwd ? { cwd: source.cwd } : {}),
...(source.terminalHandle ? { terminal: source.terminalHandle } : {})
...(source.terminalHandle ? { terminal: source.terminalHandle } : {}),
...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {})
})
if (!refreshed.ok) {
return null
@@ -51,7 +55,9 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure(
grantId: result.openTarget.grantId,
...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}),
...(source.pathText ? { pathText: source.pathText } : {}),
...(source.cwd ? { cwd: source.cwd } : {})
...(source.cwd ? { cwd: source.cwd } : {}),
...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}),
...(source.readOnly || result.openTarget.readOnly === true ? { readOnly: true as const } : {})
}
}
@@ -68,7 +74,7 @@ function isTerminalArtifactGrantFailure(
function isTerminalArtifactResolution(result: unknown): result is {
exists: true
isDirectory: false
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string }
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string; readOnly?: true }
} {
if (!result || typeof result !== 'object') {
return false
@@ -226,6 +226,13 @@ 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' } }
@@ -249,13 +256,20 @@ 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(
1,
2,
'session.tabs.createTerminal',
{
worktree: 'id:worktree-1',
env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
env: {
ANTHROPIC_BASE_URL: 'http://localhost:3000',
ORCA_ATTRIBUTION_BYPASS: '1'
},
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'ORCA_ENABLE_GIT_ATTRIBUTION'],
launchConfig: {
agentCommand: 'claude',
agentArgs: '',
@@ -269,10 +283,14 @@ 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 }
{
timeoutMs: RESUME_RPC_TIMEOUT_MS,
budgetSpansConnect: true,
expectedRuntimeId: 'runtime'
}
)
expect(sendRequest).toHaveBeenNthCalledWith(
2,
3,
'terminal.send',
{
terminal: 'pty-1',
@@ -284,17 +302,35 @@ describe('resumeAiVaultSessionInTerminal', () => {
})
it('throws when terminal creation fails', async () => {
const sendRequest = vi.fn().mockResolvedValueOnce({
ok: false,
error: { message: 'no terminal' }
})
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.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: { tab: { id: 'x' } } })
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
runtimeId: 'runtime',
capabilities: ['terminal.attribution-removed.v1']
}
})
.mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
await expect(
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
).rejects.toThrow('Created terminal response was invalid')
@@ -303,6 +339,13 @@ 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' } }
@@ -316,6 +359,13 @@ 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' } }
@@ -327,6 +377,17 @@ 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', () => {
+13 -3
View File
@@ -16,6 +16,10 @@ 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,
@@ -23,6 +27,7 @@ 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',
@@ -169,12 +174,13 @@ 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}`,
...(launch.env ? { env: launch.env } : {}),
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
env: withLegacyTerminalAttributionDisabledEnv(launch.env),
envToDelete: addLegacyTerminalAttributionDisableRequest(launch.envToDelete),
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
@@ -182,7 +188,11 @@ export async function resumeAiVaultSessionInTerminal(
select: true,
navigation: 'caller'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
{
timeoutMs: RESUME_RPC_TIMEOUT_MS,
budgetSpansConnect: true,
expectedRuntimeId: authority.runtimeId
}
)
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
@@ -53,6 +53,14 @@ 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' } }
@@ -64,9 +72,12 @@ describe('prepareMobileAiVaultSessionResume', () => {
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
expect(prepared).toBe(legacy)
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
})
expect(sendRequest).toHaveBeenNthCalledWith(
3,
4,
'terminal.send',
{
terminal: 'pty-1',
+10 -1
View File
@@ -1,5 +1,6 @@
import type {
RuntimeFileOpenResult,
RuntimeNativeChatFileContext,
RuntimeTerminalPathResolution
} from '../../../src/shared/runtime-types'
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
@@ -22,6 +23,7 @@ export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
terminalHandle?: string | null
pathText: string
cwd?: string | null
nativeChatContext?: RuntimeNativeChatFileContext | null
line: number | null
column: number | null
pushPreviewRoute: (href: ReturnType<typeof createMobileFilePreviewHref>) => void
@@ -80,7 +82,8 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
...(options.terminalHandle && options.terminalHandle.trim().length > 0
? { terminal: options.terminalHandle }
: {}),
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {})
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}),
...(options.nativeChatContext ? { nativeChatContext: options.nativeChatContext } : {})
},
{ timeoutMs: 10_000 }
)
@@ -116,6 +119,12 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
...(options.terminalHandle && options.terminalHandle.trim().length > 0
? { terminal: options.terminalHandle }
: {}),
...(options.nativeChatContext
? {
nativeChatTab: options.nativeChatContext.tabId,
nativeChatSession: options.nativeChatContext.sessionId
}
: {}),
name: displayNameFromPath(resolved.openTarget.absolutePath),
...(options.line !== null ? { line: String(options.line) } : {}),
...(options.column !== null ? { column: String(options.column) } : {}),
@@ -65,6 +65,54 @@ describe('openMobileNativeChatFileTap', () => {
)
})
it('carries the native-chat session identity into resolution and grant refresh', async () => {
const sendRequest = vi.fn(async () =>
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: '/Users/ada/orca-plans/result.html',
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
provider: 'local',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
readOnly: true
}
})
)
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({
...options,
pathText: '~/orca-plans/result.html',
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
})
await Promise.resolve()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{
worktree: 'id:wt-1',
pathText: '~/orca-plans/result.html',
crossWorkspace: true,
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
},
{ timeoutMs: 10_000 }
)
expect(options.pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
source: 'terminalArtifact',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1'
})
})
})
it('parses a :line:col citation and opens the mobile preview route', async () => {
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
const options = baseOptions({ sendRequest })
@@ -12,6 +12,7 @@ const base = {
activeTabType: 'terminal',
streamActive: true,
streamCovered: false,
streamIsLeaseOnly: false,
webViewReady: true
}
@@ -93,8 +94,40 @@ describe('mobile native-chat terminal stream lifecycle', () => {
).toBe('rearm')
})
it('resumes an uncovered handle still holding a lease-only stream', () => {
// Leaving a chat tab for a terminal tab subscribes the incoming handle before the
// route learns chat is gone, so the terminal streams nothing but its input lease.
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamIsLeaseOnly: true })).toBe(
'resume'
)
// Same wait the other resume paths take — never init a WebView that cannot receive it.
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
streamIsLeaseOnly: true,
webViewReady: false
})
).toBe('none')
})
it('leaves a lease-only stream alone while chat still covers it (#10681)', () => {
// Lease-only is the correct shape under chat; trading it for output here would
// drop the input floor the composer depends on.
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamCovered: true,
streamIsLeaseOnly: true
})
).toBe('none')
})
it('does nothing for non-terminal tabs, missing handles, or settled states', () => {
expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none')
// A full stream on an uncovered handle is the one genuinely settled shape.
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamIsLeaseOnly: false })).toBe(
'none'
)
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
@@ -8,6 +8,10 @@ export function resolveMobileNativeChatTerminalStreamAction(args: {
activeTabType: string | null
streamActive: boolean
streamCovered: boolean
/** The active stream was opened as an input lease, so the host sends `subscribed`
* and nothing else — no scrollback, no data. Distinguishes a stream that renders
* from one that only holds the input floor. */
streamIsLeaseOnly: boolean
webViewReady: boolean
}): MobileNativeChatTerminalStreamAction {
if (!args.activeHandle || args.activeTabType !== 'terminal') {
@@ -22,7 +26,13 @@ export function resolveMobileNativeChatTerminalStreamAction(args: {
// the composer locked forever — nothing else re-subscribes a covered handle.
return args.streamActive ? 'none' : 'rearm'
}
return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none'
// Why `streamIsLeaseOnly`: switching from a chat tab to a terminal tab subscribes the
// incoming handle before the route learns chat is gone, so it lands a lease-only stream
// on an uncovered handle. Without this input that is indistinguishable from a healthy
// stream and the terminal renders blank until restart.
return (args.streamCovered || args.streamIsLeaseOnly || !args.streamActive) && args.webViewReady
? 'resume'
: 'none'
}
export function isTerminalCoveredByNativeChat(
@@ -0,0 +1,81 @@
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'
)
})
})
@@ -0,0 +1,34 @@
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 })
}
+41 -13
View File
@@ -12,6 +12,10 @@ 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])
@@ -20,16 +24,27 @@ function clientReturning(...responses: RpcResponse[]) {
describe('createTerminalAndSendPrompt', () => {
it('creates a terminal then sends the prompt with enter', async () => {
const client = clientReturning(createdTerminal, sendAccepted)
const client = clientReturning(safeHost, createdTerminal, sendAccepted)
await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing')
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', {
worktree: 'id:wt-1',
activate: false,
select: true,
navigation: 'caller'
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'status.get', undefined, {
timeoutMs: 30_000,
budgetSpansConnect: true
})
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', {
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', {
terminal: 'term-1',
text: 'do the thing',
enter: true
@@ -37,28 +52,41 @@ describe('createTerminalAndSendPrompt', () => {
})
it('throws and skips terminal.send when createTerminal fails', async () => {
const client = clientReturning(failure('boom'))
const client = clientReturning(safeHost, failure('boom'))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow('boom')
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('throws when the created-terminal response is malformed', async () => {
const client = clientReturning(success({ tab: { type: 'terminal' } }))
const client = clientReturning(safeHost, success({ tab: { type: 'terminal' } }))
await expect(createTerminalAndSendPrompt(client, 'wt-1', 'p')).rejects.toThrow(
'Created terminal response was invalid'
)
expect(client.sendRequest).toHaveBeenCalledTimes(1)
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('throws when terminal.send returns a failure', async () => {
const client = clientReturning(createdTerminal, failure('send failed'))
const client = clientReturning(safeHost, 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(createdTerminal, success({ send: { accepted: false } }))
const client = clientReturning(
safeHost,
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)
})
})
+21 -6
View File
@@ -3,6 +3,14 @@ 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 —
@@ -15,12 +23,19 @@ export async function createTerminalAndSendPrompt(
worktreeId: string,
prompt: string
): Promise<void> {
const created = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
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 }
)
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
}
@@ -163,6 +163,74 @@ 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,6 +13,14 @@ 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
@@ -104,12 +112,19 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
if (!client || connState !== 'connected') {
throw new Error('Waiting for desktop...')
}
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
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 }
)
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to create terminal')
}
@@ -34,12 +34,13 @@ describe('useMobileFileTapHandlers', () => {
hostId: 'host-1',
worktreeId: 'wt-1',
worktreeName: 'Orca',
nativeChatSessionId: 'session-1',
activeHandleRef: { current: 'terminal-1' as string | null },
terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) },
openBrowser: vi.fn(),
fetchSessionTabs: vi.fn(async () => {}),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActiveSessionTabId: () => 'terminal-tab',
getActiveSessionTabType: () => 'terminal',
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
@@ -131,7 +132,12 @@ describe('useMobileFileTapHandlers', () => {
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'mobile/src/x.ts', crossWorkspace: true },
{
worktree: 'id:wt-1',
pathText: 'mobile/src/x.ts',
crossWorkspace: true,
nativeChatContext: { tabId: 'terminal-tab', sessionId: 'session-1' }
},
{ timeoutMs: 10_000 }
)
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
@@ -10,6 +10,7 @@ type MobileFileTapHandlerOptions<T extends FileTapSessionTab> = {
hostId: string
worktreeId: string
worktreeName?: string
nativeChatSessionId: string | null
activeHandleRef: MutableRefObject<string | null>
terminalCwdRef: MutableRefObject<Map<string, string>>
openBrowser: (url: string) => void
@@ -48,6 +49,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
getActiveSessionTabType,
getSessionTabs,
hostId,
nativeChatSessionId,
openBrowser,
scheduleDelayedAction,
reportChatTapFailure,
@@ -71,6 +73,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
getActiveSessionTabType,
getSessionTabs,
hostId,
nativeChatSessionId,
openBrowser,
scheduleDelayedAction,
reportChatTapFailure,
@@ -87,6 +90,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
getActiveSessionTabType,
getSessionTabs,
hostId,
nativeChatSessionId,
openBrowser,
router,
scheduleDelayedAction,
@@ -144,12 +148,18 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
return
}
const activationSeq = ++activationSeqRef.current
const nativeChatSessionId = current.nativeChatSessionId
const nativeChatTabId = current.getActiveSessionTabId()
openMobileNativeChatFileTap<T>({
client: current.client,
hostId: current.hostId,
worktreeId: current.worktreeId,
worktreeName: current.worktreeName,
pathText,
nativeChatContext:
nativeChatSessionId && nativeChatTabId
? { tabId: nativeChatTabId, sessionId: nativeChatSessionId }
: null,
pushPreviewRoute: (href) => routerRef.current.push(href),
openBrowser: current.openBrowser,
triggerOpenFeedback: triggerSelection,
@@ -8,10 +8,25 @@ describe('useMobileNativeChatTerminalStream', () => {
let harnessRenderCount = 0
const subscriptionsRef = { current: new Map<string, () => void>() }
const subscribingRef = { current: new Set<string>() }
const leaseOnlyRef = { current: new Set<string>() }
const webReadyRef = { current: new Set(['terminal-1']) }
const initializedRef = { current: new Set(['terminal-1']) }
const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {}))
const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle))
/** Mirrors the route's subscribe-time coverage guess, which is what decides whether
* the host opens a lease-only stream or a streaming one. */
let subscribeOpensLeaseOnly = false
const registerSubscription = (handle: string): void => {
subscriptionsRef.current.set(handle, () => {})
if (subscribeOpensLeaseOnly) {
leaseOnlyRef.current.add(handle)
} else {
leaseOnlyRef.current.delete(handle)
}
}
const subscribe = vi.fn(registerSubscription)
const unsubscribe = vi.fn((handle: string) => {
subscriptionsRef.current.delete(handle)
leaseOnlyRef.current.delete(handle)
})
const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} }
const notifyListedHandlesRef = { current: (_liveHandles: ReadonlySet<string>): void => {} }
const hasTabsRecoveryNeedRef = { current: (): boolean => false }
@@ -19,6 +34,8 @@ describe('useMobileNativeChatTerminalStream', () => {
beforeEach(() => {
subscriptionsRef.current = new Map([['terminal-1', () => {}]])
subscribingRef.current = new Set()
leaseOnlyRef.current = new Set()
subscribeOpensLeaseOnly = false
webReadyRef.current = new Set(['terminal-1'])
initializedRef.current = new Set(['terminal-1'])
harnessRenderCount = 0
@@ -51,6 +68,7 @@ describe('useMobileNativeChatTerminalStream', () => {
streamRevision,
subscriptionsRef,
subscribingRef,
leaseOnlyRef,
webReadyRef,
initializedRef,
subscribe,
@@ -107,6 +125,111 @@ describe('useMobileNativeChatTerminalStream', () => {
expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
})
/** What switchTab does synchronously on the tap, before the route re-renders: the
* coverage it reads still describes the chat tab being left, so the incoming
* terminal gets a lease-only subscribe — the shape that renders nothing. */
function handOffToTerminalTwo(): void {
subscribeOpensLeaseOnly = true
subscribe('terminal-2')
subscribeOpensLeaseOnly = false
subscribe.mockClear()
unsubscribe.mockClear()
}
it('trades a lease-only stream for output when a chat tab hands off to a terminal tab', async () => {
webReadyRef.current.add('terminal-2')
initializedRef.current.add('terminal-2')
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
handOffToTerminalTwo()
await act(async () => {
renderer?.update(
createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' })
)
})
expect(unsubscribe).toHaveBeenCalledWith('terminal-2')
expect(subscribe).toHaveBeenCalledWith('terminal-2')
expect(leaseOnlyRef.current.has('terminal-2')).toBe(false)
// The lease-only stream carried no scrollback, so xterm is empty — a stale
// initialized mark would drop the replacement snapshot and keep the tab blank.
expect(initializedRef.current.has('terminal-2')).toBe(false)
})
it('settles once a lease-only stream has been replaced by a full one', async () => {
webReadyRef.current.add('terminal-2')
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
handOffToTerminalTwo()
await act(async () => {
renderer?.update(
createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' })
)
})
subscribe.mockClear()
unsubscribe.mockClear()
await act(async () => {
renderer?.update(
createElement(Harness, {
showNativeChat: false,
activeHandle: 'terminal-2',
streamRevision: 1
})
)
})
// A stream that renders is settled — resubscribing it again would be a loop.
expect(subscribe).not.toHaveBeenCalled()
expect(unsubscribe).not.toHaveBeenCalled()
})
it('repairs a lease-only stream once its WebView reports ready', async () => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
handOffToTerminalTwo()
await act(async () => {
renderer?.update(
createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' })
)
})
// Cold WebView: the resume must wait, and the route's own web-ready path bails
// because the lease-only subscription already counts as live.
expect(subscribe).not.toHaveBeenCalled()
await act(async () => {
webReadyRef.current.add('terminal-2')
notifyWebReadyRef.current('terminal-2', false)
})
expect(unsubscribe).toHaveBeenCalledWith('terminal-2')
expect(subscribe).toHaveBeenCalledWith('terminal-2')
})
it('keeps a covered lease-only stream instead of trading it for output (#10681)', async () => {
subscribeOpensLeaseOnly = true
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
subscribe.mockClear()
unsubscribe.mockClear()
await act(async () => {
renderer?.update(createElement(Harness, { showNativeChat: true, streamRevision: 1 }))
})
// Lease-only is the correct shape under chat; swapping it for output would drop
// the input floor the composer holds.
expect(subscribe).not.toHaveBeenCalled()
expect(unsubscribe).not.toHaveBeenCalled()
expect(leaseOnlyRef.current.has('terminal-1')).toBe(true)
})
it('re-subscribes a covered stream torn down under chat (#10681)', async () => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
@@ -174,9 +297,7 @@ describe('useMobileNativeChatTerminalStream', () => {
expect(subscribe).toHaveBeenCalledTimes(5)
} finally {
subscribe.mockImplementation((handle: string) =>
subscriptionsRef.current.set(handle, () => {})
)
subscribe.mockImplementation(registerSubscription)
}
})
@@ -23,6 +23,8 @@ export function useMobileNativeChatTerminalStream(args: {
streamRevision: number
subscriptionsRef: MutableRefObject<Map<string, () => void>>
subscribingRef: MutableRefObject<Set<string>>
/** Handles whose current subscribe went out as an input lease only. */
leaseOnlyRef: MutableRefObject<Set<string>>
webReadyRef: MutableRefObject<Set<string>>
initializedRef: MutableRefObject<Set<string>>
subscribe: (handle: string) => void
@@ -55,13 +57,21 @@ export function useMobileNativeChatTerminalStream(args: {
healthyStreamProofRef.current = null
}
}, [])
const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => {
// Why: ordinary WebView startups must not rerender the large session route;
// only readiness that can release a native-chat lease needs reconciliation.
if (!wasAlreadyReady && coveredHandleRef.current === handle) {
setWebReadyRevision((revision) => revision + 1)
}
}, [])
const notifyWebReady = useCallback(
(handle: string, wasAlreadyReady: boolean) => {
// Why: ordinary WebView startups must not rerender the large session route;
// only readiness that can release a native-chat lease needs reconciliation.
// A lease-only stream counts: the route's own web-ready path bails on any live
// subscription, so nothing else would ever trade that lease for output.
if (
!wasAlreadyReady &&
(coveredHandleRef.current === handle || args.leaseOnlyRef.current.has(handle))
) {
setWebReadyRevision((revision) => revision + 1)
}
},
[args.leaseOnlyRef]
)
const notifyListedHandles = useCallback(
(liveHandles: ReadonlySet<string>) => {
// Why: the rearm budget bounds one teardown, not the handle's lifetime. A covered
@@ -106,7 +116,7 @@ export function useMobileNativeChatTerminalStream(args: {
(rearmAttemptsRef.current.get(handle) ?? 0) >= MAX_REARM_ATTEMPTS
)
}, [])
useEffect(() => {
const reconcileStream = useCallback(() => {
const handle = args.activeHandle
if (coveredHandleRef.current && coveredHandleRef.current !== handle) {
forgetRearmState(coveredHandleRef.current)
@@ -115,12 +125,15 @@ export function useMobileNativeChatTerminalStream(args: {
const streamActive =
handle != null &&
(args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle))
const streamIsLeaseOnly =
streamActive && handle != null && args.leaseOnlyRef.current.has(handle)
const action = resolveMobileNativeChatTerminalStreamAction({
showNativeChat: args.showNativeChat,
activeHandle: handle,
activeTabType: args.activeTabType,
streamActive,
streamCovered: coveredHandleRef.current === handle,
streamIsLeaseOnly,
webViewReady: handle != null && args.webReadyRef.current.has(handle)
})
const streamHolding = action === 'none' && streamActive && coveredHandleRef.current === handle
@@ -181,7 +194,11 @@ export function useMobileNativeChatTerminalStream(args: {
args.subscribe(handle)
return
}
if (coveredHandleRef.current === handle) {
if (coveredHandleRef.current === handle || streamIsLeaseOnly) {
// Why clear `initialized`: a lease-only stream delivered no scrollback, so xterm
// holds nothing — a stale mark would drop the replacement snapshot and the
// terminal would stay blank through the resubscribe.
args.initializedRef.current.delete(handle)
args.unsubscribe(handle)
coveredHandleRef.current = null
}
@@ -190,6 +207,7 @@ export function useMobileNativeChatTerminalStream(args: {
args.activeHandle,
args.activeTabType,
args.initializedRef,
args.leaseOnlyRef,
args.leaseReady,
args.showNativeChat,
args.streamRevision,
@@ -203,6 +221,7 @@ export function useMobileNativeChatTerminalStream(args: {
rearmBudgetRevision,
webReadyRevision
])
useEffect(() => reconcileStream(), [reconcileStream])
useEffect(() => cancelHealthyStreamProof, [cancelHealthyStreamProof])
// Why memoized: the session route keeps this object in callback dep arrays, and a
// fresh literal per render would rebuild them on every keystroke. All three members
+10 -14
View File
@@ -3,11 +3,12 @@
// one path span containing the tapped column — mobile opens a tapped path, it
// does not render hover links over the whole line.
export type TappedFilePath = {
pathText: string
line: number | null
column: number | null
}
import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
export type TappedFilePath = ParsedFileLinkLocation
// Separator-anchored path tokens (absolute, relative, ~/, drive-letter, UNC) OR
// a bare filename with an extension (README.md, index.ts), optionally suffixed
@@ -47,18 +48,13 @@ function trimBoundaryPunctuation(
}
export function parsePathWithOptionalLineColumn(value: string): TappedFilePath | null {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
if (!match) {
const parsed = parseFileLinkLocation(value)
if (!parsed) {
return null
}
const pathText = match[1]
const { pathText, line, column } = parsed
// Reject a directory-only token (trailing separator) for either slash style.
if (!pathText || pathText.endsWith('/') || pathText.endsWith('\\')) {
return null
}
const line = match[2] ? Number.parseInt(match[2], 10) : null
const column = match[3] ? Number.parseInt(match[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
if (pathText.endsWith('/') || pathText.endsWith('\\')) {
return null
}
return { pathText, line, column }
+10 -1
View File
@@ -54,6 +54,7 @@ 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`
@@ -1013,7 +1014,15 @@ export function connect(
}
})
if (!sendEncrypted({ id, deviceToken, method, params })) {
if (
!sendEncrypted({
id,
deviceToken,
method,
params,
...(options?.expectedRuntimeId ? { expectedRuntimeId: options.expectedRuntimeId } : {})
})
) {
pending.delete(id)
clearTimeout(timeout)
reject(new Error('Connection interrupted'))
+1
View File
@@ -18,6 +18,7 @@ export type RpcRequest = {
deviceToken: string
method: string
params?: unknown
expectedRuntimeId?: string
}
export type RpcSuccess = {
+1
View File
@@ -87,6 +87,7 @@
"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: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: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",
+64
View File
@@ -61,3 +61,67 @@ describe('terminal close CLI', () => {
expect(help).toContain('durable persistence')
})
})
describe('terminal split CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends the old-host attribution bypass through the legacy-compatible env field', async () => {
const call = vi.fn(async (method: string) =>
method === 'status.get'
? {
result: {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}
}
: { result: { split: { handle: 'term-2', parentHandle: 'term-1' } } }
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal split']({
flags: new Map([
['terminal', 'term-1'],
['direction', 'horizontal']
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenNthCalledWith(1, 'status.get')
expect(call).toHaveBeenNthCalledWith(
2,
'terminal.split',
{
terminal: 'term-1',
direction: 'horizontal',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => {
const call = vi.fn().mockResolvedValue({
result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] }
})
await expect(
TERMINAL_HANDLERS['terminal split']({
flags: new Map([['terminal', 'term-1']]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
).rejects.toMatchObject({
code: 'runtime_update_required',
message: expect.stringContaining('Update the host and try again')
})
expect(call).toHaveBeenCalledTimes(1)
expect(call).not.toHaveBeenCalledWith('terminal.split', expect.anything())
})
})
+52 -17
View File
@@ -8,7 +8,8 @@ import type {
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait
RuntimeTerminalWait,
RuntimeStatus
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
@@ -37,6 +38,14 @@ import {
getRequiredWorktreeSelector,
getTerminalHandle
} from '../selectors'
import {
addLegacyTerminalAttributionDisableRequest,
hostSupportsTerminalCreateAttributionDisable,
hostSupportsTerminalSplitAttributionDisable,
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
withLegacyTerminalAttributionDisabledEnv
} from '../../shared/legacy-terminal-attribution-env'
// Why: terminal wait legitimately needs to outlive the CLI's default RPC
// timeout. Even without an explicit server timeout, the client must allow
@@ -137,17 +146,30 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
const useRendererBackedInteractiveTerminal =
!client.isRemote && shouldUseRendererBackedInteractiveTerminal(command)
const focus = flags.get('focus') === true
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command,
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
// unless the caller explicitly asks for focus.
focus,
...(focus ? { presentation: 'focused' } : {}),
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
})
const status = await client.call<RuntimeStatus>('status.get')
if (!hostSupportsTerminalCreateAttributionDisable(status.result)) {
throw new RuntimeClientError(
'runtime_update_required',
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
}
const result = await client.call<{ terminal: RuntimeTerminalCreate }>(
'terminal.create',
{
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command,
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
// unless the caller explicitly asks for focus.
focus,
...(focus ? { presentation: 'focused' } : {}),
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
},
{ expectedRuntimeId: status.result.runtimeId }
)
printResult(result, json, formatTerminalCreate)
},
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
@@ -168,11 +190,24 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
) {
throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical')
}
const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', {
terminal: await getTerminalHandle(flags, cwd, client),
direction: directionFlag,
command: getOptionalStringFlag(flags, 'command')
})
const status = await client.call<RuntimeStatus>('status.get')
if (!hostSupportsTerminalSplitAttributionDisable(status.result)) {
throw new RuntimeClientError(
'runtime_update_required',
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
}
const result = await client.call<{ split: RuntimeTerminalSplit }>(
'terminal.split',
{
terminal: await getTerminalHandle(flags, cwd, client),
direction: directionFlag,
command: getOptionalStringFlag(flags, 'command'),
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined)
},
{ expectedRuntimeId: status.result.runtimeId }
)
printResult(result, json, formatTerminalSplit)
}
}
+202 -82
View File
@@ -3112,6 +3112,10 @@ describe('orca cli worktree awareness', () => {
it('passes explicit focus through terminal.create', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3136,13 +3140,19 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: undefined,
title: 'RUNNER',
focus: true,
presentation: 'focused'
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'RUNNER',
focus: true,
presentation: 'focused'
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('prints terminal.read fallback screen lines in json mode', async () => {
@@ -3184,6 +3194,10 @@ describe('orca cli worktree awareness', () => {
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3209,19 +3223,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3248,20 +3272,30 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3287,17 +3321,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
title: 'Codex exec',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex exec',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3323,17 +3367,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
title: 'Codex exec',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex exec',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for Codex review commands after long options', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3359,17 +3413,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
title: 'Codex review',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex review',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not force the visible terminal path for Codex help commands', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3395,17 +3459,27 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
title: 'Codex help',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex help',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3431,19 +3505,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3469,19 +3553,29 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude',
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'claude',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('keeps Claude print commands on the background terminal path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3507,12 +3601,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
title: 'Claude print',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Claude print',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
@@ -3819,6 +3919,10 @@ describe('orca cli worktree awareness', () => {
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3842,12 +3946,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: undefined,
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('collects and formats memory diagnostics', async () => {
@@ -3955,6 +4065,10 @@ describe('orca cli worktree awareness', () => {
it('does not force remote Codex terminal creates through a local renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3982,12 +4096,18 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
title: 'Codex',
focus: false
})
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
+1
View File
@@ -87,6 +87,7 @@ export class RuntimeClient {
}
: {}
const envelope = {
expectedRuntimeId: options?.expectedRuntimeId,
orchestrationCapability: options?.orchestrationCapability,
orchestrationContractVersion: method.startsWith('orchestration.')
? ORCHESTRATION_CONTRACT_VERSION
+42
View File
@@ -50,6 +50,48 @@ describe('runtime transport timeout validation', () => {
// Why: these tests create Unix domain socket servers in temp directories.
// Windows does not support Unix domain sockets in the same way.
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
it('forwards the expected runtime fence in local requests', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
let receivedRequest: Record<string, unknown> | null = null
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
socket.write(
`${JSON.stringify({
id: receivedRequest.id,
ok: true,
result: { terminal: { handle: 'terminal-1' } },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
await sendRequest(
{
runtimeId: 'runtime-1',
pid: 123,
transports: [{ kind: 'unix', endpoint }],
authToken: 'token',
startedAt: 1
},
'terminal.create',
{ worktree: 'id:worktree-1' },
1_000,
{ expectedRuntimeId: 'runtime-1' }
)
expect(receivedRequest).toMatchObject({
method: 'terminal.create',
expectedRuntimeId: 'runtime-1'
})
})
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
+1
View File
@@ -186,6 +186,7 @@ export async function sendRequest<TResult>(
authToken: metadata.authToken,
method,
params,
expectedRuntimeId: envelope?.expectedRuntimeId,
orchestrationCapability: envelope?.orchestrationCapability,
orchestrationContractVersion: envelope?.orchestrationContractVersion,
orchestrationRequestId: envelope?.orchestrationRequestId,
@@ -2,7 +2,7 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileStat } from '../providers/types'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import { recordSessionScanIssue } from './session-scan-issues'
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
@@ -32,7 +32,7 @@ export async function statRemoteSessionFile(
} catch (error) {
throwIfAiVaultScanCancelled(options?.signal)
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
recordRemoteSessionScanIssue(issues, {
recordSessionScanIssue(issues, {
executionHostId,
agent,
path,
@@ -1,24 +0,0 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const REMOTE_SCAN_ISSUE_LIMIT = 500
export function recordRemoteSessionScanIssue(
issues: AiVaultScanIssue[],
issue: AiVaultScanIssue
): void {
if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) {
issues.push(issue)
return
}
if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) {
// Kinded: this row is a scan notice, not a skipped transcript — the panel
// counts unkinded issues as skipped transcript files.
issues.push({
executionHostId: issue.executionHostId,
agent: issue.agent,
kind: 'notice',
path: 'Agent Session History scan',
message: 'Additional scan issues were omitted.'
})
}
}
@@ -7,7 +7,7 @@ import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import { recordSessionScanIssue } from './session-scan-issues'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
@@ -124,7 +124,7 @@ function recordRemoteDirectoryIssue(
err: unknown
): void {
if (!isMissingRemoteSessionPathError(err)) {
recordRemoteSessionScanIssue(issues, {
recordSessionScanIssue(issues, {
executionHostId,
agent: source.agent,
kind: 'host',
+3 -3
View File
@@ -24,7 +24,7 @@ import { createAntigravityWorkspaceResolver } from './session-scanner-antigravit
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import { recordSessionScanIssue } from './session-scan-issues'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
@@ -205,7 +205,7 @@ async function scanRemoteInScopeSessions(args: {
}
if (index < candidates.length && sessions.length < args.limit) {
recordRemoteSessionScanIssue(args.issues, {
recordSessionScanIssue(args.issues, {
executionHostId: args.context.executionHostId,
agent: 'codex',
kind: 'scope',
@@ -241,7 +241,7 @@ async function parseRemoteSessionCandidate(
return session
} catch (err) {
throwIfAiVaultScanCancelled(context.signal)
recordRemoteSessionScanIssue(issues, {
recordSessionScanIssue(issues, {
executionHostId: context.executionHostId,
agent: candidate.source.agent,
path: candidate.file.path,
@@ -1,10 +1,10 @@
import { stat } from 'node:fs/promises'
import type {
AiVaultAgent,
AiVaultFirstUserPromptResult,
AiVaultSession
} from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
@@ -118,7 +118,9 @@ async function fileWithMtimeForPath(
}
try {
const info = await stat(filePath)
// 'scan' matches the parser this feeds, so the two halves of one re-parse
// share a lane instead of the stat jumping the live-transcript queue.
const info = await wslGatedStat(filePath, 'scan')
return {
path: filePath,
mtimeMs: info.mtimeMs,
+27
View File
@@ -0,0 +1,27 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
// Why: a stalled WSL distro or an unreachable remote host fails one probe per
// discovered path, so an uncapped list ships one row per transcript file in the
// tree over IPC. Local and remote scans share the bound — the failure shape is
// the same and both feed the same panel.
const SCAN_ISSUE_LIMIT = 500
/** Append a scan issue, collapsing everything past the cap into one notice. */
export function recordSessionScanIssue(issues: AiVaultScanIssue[], issue: AiVaultScanIssue): void {
if (issues.length < SCAN_ISSUE_LIMIT - 1) {
issues.push(issue)
return
}
if (issues.length === SCAN_ISSUE_LIMIT - 1) {
// Kinded: this row is a scan notice, not a skipped transcript — the panel
// counts unkinded issues as skipped transcript files. Spread first so a
// remote issue keeps its executionHostId and a local one never gains an
// explicit `undefined` (which would outrank the scanner's own stamping).
issues.push({
...issue,
kind: 'notice',
path: 'Agent Session History scan',
message: 'Additional scan issues were omitted.'
})
}
}
@@ -1,8 +1,28 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values'
const HISTORY_MATCH_WINDOW_MS = 2_000
/**
* The local-scan `readHistory`; remote scans inject their own transport. A
* missing or unreadable history file is genuinely "no enrichment", but a gate
* refusal must propagate so the caller records a scan issue — degrading it to
* null lists the session with a missing cwd and no retry signal, and the
* resolver's memo relies on the rejection to evict rather than pin a stall.
*/
export async function readLocalAntigravityHistory(path: string): Promise<string | null> {
try {
return await wslGatedReadFile(path, 'utf-8', 'scan')
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
}
type AntigravityHistoryEntry = {
timestampMs: number
workspace: string
@@ -26,8 +46,19 @@ export function createAntigravityWorkspaceResolver(
}
let index = indexes.get(historyPath)
if (!index) {
index = readHistory(historyPath).then(indexAntigravityHistory)
indexes.set(historyPath, index)
// Why: a read failure is transient (a stalled WSL distro refuses here),
// so it must not be memoized — every later session under this history
// file would inherit the rejection for the process lifetime.
const pending: Promise<AntigravityHistoryIndex> = readHistory(historyPath)
.then(indexAntigravityHistory)
.catch((error: unknown) => {
if (indexes.get(historyPath) === pending) {
indexes.delete(historyPath)
}
throw error
})
index = pending
indexes.set(historyPath, pending)
}
const workspace = findAntigravityWorkspace(session, await index)
return workspace ? { ...session, cwd: workspace } : session
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -26,11 +26,16 @@ export async function parseAntigravitySessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseAntigravitySessionLines({ file, lines, platform })
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseAntigravitySessionLines({ file, lines, platform })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
export async function parseAntigravitySessionContent(
@@ -1,5 +1,3 @@
import { createReadStream } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type {
@@ -8,6 +6,14 @@ import type {
AiVaultSubagentListResult,
AiVaultSubagentRunStatus
} from '../../shared/ai-vault-types'
import {
openTranscriptReadStream,
wslGatedReaddir,
wslGatedReadFile,
wslGatedStat
} from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import {
@@ -29,6 +35,10 @@ const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000
// Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY):
// opening every subagent read stream at once stalls over SSH/WSL UNC paths.
const SUBAGENT_PARSE_CONCURRENCY = 8
// 'scan', not 'exact', even though a user expands this on demand: it is bulk
// directory work feeding the same parsers as the main scan, and the exact lane
// stays reserved for live transcript probes.
const SUBAGENT_FS_PRIORITY = 'scan'
const TASK_NOTIFICATION_MARKER = '<task-notification>'
const TOOL_USE_RESULT_MARKER = '"toolUseResult"'
@@ -71,8 +81,13 @@ export async function listClaudeSubagentSessions(args: {
let entries
try {
entries = await readdir(subagentsDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(subagentsDir, SUBAGENT_FS_PRIORITY)
} catch (err) {
// A gate refusal is a stalled distro, not a session without subagents —
// report it so the panel offers a retry instead of showing an empty list.
if (err instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, { agent: 'claude', path: subagentsDir, message: err.message })
}
return { sessions: [], issues }
}
@@ -130,7 +145,7 @@ async function parseSubagentTranscript(args: {
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession | null> {
try {
const fileStat = await stat(args.filePath)
const fileStat = await wslGatedStat(args.filePath, SUBAGENT_FS_PRIORITY)
const session = await parseClaudeSessionFile(
{
path: args.filePath,
@@ -161,7 +176,11 @@ async function parseSubagentTranscript(args: {
}
}
} catch (err) {
args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) })
recordSessionScanIssue(args.issues, {
agent: 'claude',
path: args.filePath,
message: errorMessage(err)
})
return null
}
}
@@ -187,11 +206,13 @@ function resolveSubagentStatus(args: {
// 'async_launched', notification 'running') are superseded by terminal ones.
async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<string, string>> {
const statuses = new Map<string, string>()
const input = openTranscriptReadStream(
parentFilePath,
{ encoding: 'utf-8' },
SUBAGENT_FS_PRIORITY
)
const lines = createInterface({ input, crlfDelay: Infinity })
try {
const lines = createInterface({
input: createReadStream(parentFilePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const hasNotification = line.includes(TASK_NOTIFICATION_MARKER)
const hasTaskResult =
@@ -232,6 +253,11 @@ async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<
}
} catch {
// A missing/unreadable parent transcript degrades to recency-only status.
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-read failure cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
return statuses
}
@@ -269,7 +295,8 @@ function taskNotificationBlockText(block: unknown): string {
async function readSubagentMeta(transcriptPath: string): Promise<ClaudeSubagentMeta> {
const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json`
try {
const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown)
const raw = await wslGatedReadFile(metaPath, 'utf-8', SUBAGENT_FS_PRIORITY)
const record = asRecord(JSON.parse(raw) as unknown)
return {
description: normalizeTitleText(extractString(record?.description) ?? ''),
agentType: extractString(record?.agentType)
@@ -1,4 +1,4 @@
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
@@ -42,7 +42,7 @@ export async function parseCodexSessionFile(
executionHostId?: ExecutionHostId
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
@@ -1,7 +1,7 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
// Codex names threads lazily in <CODEX_HOME>/session_index.jsonl; transcripts
@@ -77,7 +77,7 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<strin
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
let signature: string
try {
const indexStat = await stat(indexPath)
const indexStat = await wslGatedStat(indexPath, 'scan')
signature = `${indexStat.size}:${indexStat.mtimeMs}`
} catch {
return new Map()
@@ -88,10 +88,14 @@ async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<strin
return cachedTitles
}
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
signature,
titles
}))
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then(({ titles, refused }) => {
// Why: a gate refusal is a stalled distro, not a title-less index — caching
// it would pin "no titles" until the index's size or mtime changes.
if (refused && codexSessionIndexTitleCache.get(codexHome) === pending) {
codexSessionIndexTitleCache.delete(codexHome)
}
return { signature, titles }
})
storeCodexSessionIndexTitleCacheEntry(codexHome, pending)
return (await pending).titles
}
@@ -133,11 +137,11 @@ function storeCodexSessionIndexTitleCacheEntry(
async function readCodexSessionIndexTitlesFromDisk(
indexPath: string
): Promise<Map<string, string>> {
): Promise<{ titles: Map<string, string>; refused: boolean }> {
const titleBySessionId = new Map<string, string>()
try {
const lines = createInterface({
input: createReadStream(indexPath, { encoding: 'utf-8' }),
input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
for await (const line of lines) {
@@ -151,8 +155,9 @@ async function readCodexSessionIndexTitlesFromDisk(
titleBySessionId.set(sessionId, title)
}
}
} catch {
} catch (error) {
// Codex creates the index opportunistically; older homes may only have raw transcripts.
return { titles: titleBySessionId, refused: error instanceof WslTranscriptFsError }
}
return titleBySessionId
return { titles: titleBySessionId, refused: false }
}
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -35,7 +35,7 @@ export async function parseCopilotSessionFile(
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCopilotSessionLines({ file, lines, platform })
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type * as NodePathModule from 'node:path'
import type { SessionFileCandidate } from './session-scanner-types'
const mocks = vi.hoisted(() => ({
open: vi.fn(),
readFile: vi.fn(),
readdir: vi.fn(),
stat: vi.fn()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
open: mocks.open,
readFile: mocks.readFile,
readdir: mocks.readdir,
stat: mocks.stat
}))
// Derived sibling paths must retain Windows separators in this cross-platform test.
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof NodePathModule>()
return { ...actual.win32, default: actual.win32 }
})
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from './session-scanner-parse-cache'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
function uncPath(distro: string, ...segments: string[]): string {
return `\\\\wsl.localhost\\${distro}\\home\\ada\\${segments.join('\\')}`
}
const KIMI_HOME = ['.kimi-code', 'sessions', 'wd_app_9f2', 'session_abc']
const GROK_DIR = ['.grok', 'sessions', 'ses-1']
const OPENCODE_ROOT = ['.local', 'share', 'opencode', 'storage']
const KIMI_STATE = JSON.stringify({
title: 'Kimi session',
createdAt: '2026-06-01T10:00:00.000Z',
updatedAt: '2026-06-01T10:05:00.000Z',
agents: { main: { type: 'main', parentAgentId: null } }
})
const KIMI_WIRE = `${JSON.stringify({
type: 'context.append_message',
message: { role: 'user', origin: { kind: 'user' }, content: 'ship it' }
})}\n`
const GROK_SESSION = JSON.stringify({
info: { id: 'ses-1', cwd: '/repo/app' },
generated_title: 'Grok session',
created_at: '2026-06-01T10:00:00.000Z'
})
const GROK_HISTORY = `${JSON.stringify({
type: 'user',
content: '<user_query>ship it</user_query>'
})}\n`
const OPENCODE_SESSION = JSON.stringify({
id: 'ses-1',
title: 'OpenCode session',
directory: '/repo/app',
time: { created: 1780000000000 }
})
const OPENCODE_MESSAGE = JSON.stringify({
role: 'user',
content: [{ type: 'text', text: 'ship it' }],
time: { created: 1780000000000 }
})
type ReadResult = { bytesRead: number; buffer: Buffer }
let releaseStall: (() => void) | undefined
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) } as T)
})
}
function stallingHandle() {
return { read: vi.fn(stalls<ReadResult>), close: vi.fn(async () => {}) }
}
function servingHandle(body: string) {
const bytes = Buffer.from(body)
return {
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = bytes.subarray(position, Math.min(position + length, bytes.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}),
close: vi.fn(async () => {})
}
}
function missing(): Error {
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
function dirent(name: string) {
return { name, isFile: () => true }
}
function candidate(agent: SessionFileCandidate['agent'], path: string): SessionFileCandidate {
return {
agent,
file: {
path,
mtimeMs: 1,
modifiedAt: '2026-06-01T10:05:00.000Z',
sizeBytes: 128
},
codexHome: null
}
}
async function expectRefusal(target: SessionFileCandidate): Promise<void> {
const refusal = expect(parseAgentSessionFileCached(target, 'linux')).rejects.toBeInstanceOf(
WslTranscriptFsError
)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await refusal
}
async function releaseAndSettle(): Promise<void> {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
resetSessionParseCacheForTests()
mocks.open.mockReset()
mocks.readFile.mockReset()
mocks.readdir.mockReset()
mocks.stat.mockReset()
releaseStall = undefined
mocks.stat.mockRejectedValue(missing())
vi.useFakeTimers()
})
afterEach(async () => {
await releaseAndSettle()
vi.useRealTimers()
})
describe('Kimi session parse against a stalled WSL transcript', () => {
it('refuses a stalled state.json read instead of reporting no session', async () => {
const state = uncPath('KimiState', ...KIMI_HOME, 'state.json')
mocks.readFile.mockImplementation(stalls<string>)
await expectRefusal(candidate('kimi', state))
})
it('refuses a stalled wire read, then caches the full session once it recovers', async () => {
const state = uncPath('KimiWire', ...KIMI_HOME, 'state.json')
mocks.readFile.mockResolvedValue(KIMI_STATE)
mocks.open.mockResolvedValue(stallingHandle())
await expectRefusal(candidate('kimi', state))
await releaseAndSettle()
mocks.open.mockResolvedValue(servingHandle(KIMI_WIRE))
const recovered = await parseAgentSessionFileCached(candidate('kimi', state), 'linux')
expect(recovered?.messageCount).toBe(1)
})
// Pins the narrowness of the parser's `instanceof WslTranscriptFsError`
// rethrow: widening it to every error turns this into a hard scan issue.
it('still returns a metadata-only session when the wire file is simply missing', async () => {
const state = uncPath('KimiMissing', ...KIMI_HOME, 'state.json')
mocks.readFile.mockResolvedValue(KIMI_STATE)
mocks.open.mockRejectedValue(missing())
const session = await parseAgentSessionFileCached(candidate('kimi', state), 'linux')
expect(session).toMatchObject({ agent: 'kimi', title: 'Kimi session', messageCount: 0 })
})
})
describe('Grok session parse against a stalled WSL transcript', () => {
it('refuses a stalled chat_history read instead of reporting a summary-only session', async () => {
const file = uncPath('GrokHistory', ...GROK_DIR, 'session.json')
mocks.readFile.mockResolvedValue(GROK_SESSION)
mocks.open.mockResolvedValue(stallingHandle())
await expectRefusal(candidate('grok', file))
await releaseAndSettle()
mocks.open.mockResolvedValue(servingHandle(GROK_HISTORY))
const recovered = await parseAgentSessionFileCached(candidate('grok', file), 'linux')
expect(recovered?.previewMessages).toEqual([
expect.objectContaining({ role: 'user', text: 'ship it' })
])
})
it('still returns the summary-only session when chat_history is missing', async () => {
const file = uncPath('GrokMissing', ...GROK_DIR, 'session.json')
mocks.readFile.mockResolvedValue(GROK_SESSION)
mocks.open.mockRejectedValue(missing())
const session = await parseAgentSessionFileCached(candidate('grok', file), 'linux')
expect(session).toMatchObject({
agent: 'grok',
title: 'Grok session',
previewMessages: []
})
})
})
describe('OpenCode session parse against a stalled WSL transcript', () => {
function sessionPath(distro: string): string {
return uncPath(distro, ...OPENCODE_ROOT, 'session', 'prj', 'ses-1.json')
}
it('refuses a stalled message-directory listing instead of an empty transcript', async () => {
mocks.readFile.mockResolvedValue(OPENCODE_SESSION)
mocks.readdir.mockImplementation(stalls<unknown[]>)
await expectRefusal(candidate('opencode', sessionPath('OpenCodeDir')))
})
it('refuses a stalled per-message read instead of a partial transcript', async () => {
const file = sessionPath('OpenCodeMessage')
mocks.readdir.mockResolvedValue([dirent('msg-1.json')])
mocks.readFile.mockImplementation((path: string) =>
path === file ? Promise.resolve(OPENCODE_SESSION) : stalls<string>()
)
await expectRefusal(candidate('opencode', file))
await releaseAndSettle()
mocks.readFile.mockImplementation((path: string) =>
Promise.resolve(path === file ? OPENCODE_SESSION : OPENCODE_MESSAGE)
)
const recovered = await parseAgentSessionFileCached(candidate('opencode', file), 'linux')
expect(recovered?.messageCount).toBe(1)
})
// Same guard as Kimi's: a live process mid-write must stay a parseable
// session, not a refusal.
it('still returns the session when a live process left a half-written message', async () => {
const file = sessionPath('OpenCodeHalfWritten')
mocks.readdir.mockResolvedValue([dirent('msg-1.json')])
mocks.readFile.mockImplementation((path: string) =>
Promise.resolve(path === file ? OPENCODE_SESSION : '{"role":"user",')
)
const session = await parseAgentSessionFileCached(candidate('opencode', file), 'linux')
expect(session).toMatchObject({ agent: 'opencode', title: 'OpenCode session', messageCount: 0 })
})
})
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -33,7 +33,7 @@ export async function parseCursorSessionFile(
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCursorSessionLines({ file, lines, platform })
@@ -1,4 +1,4 @@
import { readFile } from 'node:fs/promises'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
@@ -27,7 +27,11 @@ export async function parseDevinSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
return parseDevinSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
)
}
export function parseDevinSessionContent(
@@ -1,6 +1,54 @@
import { describe, expect, it } from 'vitest'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { walkSessionFiles } from './session-scanner-discovery'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Dirent } from 'node:fs'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const fsMocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
readdir: fsMocks.readdir,
stat: fsMocks.stat
}))
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
import { discoverFiles, walkSessionFiles } from './session-scanner-discovery'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
function dirent(name: string): Dirent {
return { name, isDirectory: () => false, isFile: () => true } as Dirent
}
let releaseStall: (() => void) | undefined
function stalls<T>(): Promise<T> {
return new Promise<T>((resolve) => {
releaseStall = () => resolve(undefined as T)
})
}
beforeEach(() => {
fsMocks.readdir.mockReset()
fsMocks.stat.mockReset()
fsMocks.readdir.mockResolvedValue([])
releaseStall = undefined
})
// A stalled task holds its gate permit until the call settles; releasing it
// keeps the next case from fast-failing on an already-exhausted gate.
afterEach(async () => {
if (!vi.isFakeTimers()) {
return
}
releaseStall?.()
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
})
describe('walkSessionFiles WSL gate refusals', () => {
it('rethrows gate refusals instead of reporting an empty tree', async () => {
@@ -26,3 +74,64 @@ describe('walkSessionFiles WSL gate refusals', () => {
).resolves.toEqual([])
})
})
describe('discoverFiles containment for a stalled WSL root', () => {
const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions'
it('contains a stalled readdir to one root so a Promise.all fan-out still resolves', async () => {
vi.useFakeTimers()
{
fsMocks.readdir.mockImplementation((dir: string) =>
dir === UNC_ROOT ? stalls<Dirent[]>() : Promise.resolve([])
)
const issues: AiVaultScanIssue[] = []
const discovery = discoverFiles({
rootDir: UNC_ROOT,
limit: 10,
agent: 'codex',
issues,
extensions: ['.jsonl']
})
const healthy = discoverFiles({
rootDir: '/home/ada/.codex/sessions',
limit: 10,
agent: 'codex',
issues: [],
extensions: ['.jsonl']
})
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await expect(Promise.all([discovery, healthy])).resolves.toEqual([
{ agent: 'codex', rootDir: UNC_ROOT, files: [] },
{ agent: 'codex', rootDir: '/home/ada/.codex/sessions', files: [] }
])
expect(issues).toEqual([{ agent: 'codex', path: UNC_ROOT, message: SLOW_MESSAGE }])
}
})
it('reports every stalled file without spending one deadline per file', async () => {
vi.useFakeTimers()
{
const names = ['a', 'b', 'c', 'd', 'e'].map((name) => dirent(`${name}.jsonl`))
fsMocks.readdir.mockResolvedValue(names)
fsMocks.stat.mockImplementation(stalls)
const issues: AiVaultScanIssue[] = []
const discovery = discoverFiles({
rootDir: UNC_ROOT,
limit: 10,
agent: 'codex',
issues,
extensions: ['.jsonl']
})
// Two deadlines total, not five: once the first stalled stat is marked
// stuck, every later file fast-fails on the route instead of queueing.
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
await expect(discovery).resolves.toEqual({ agent: 'codex', rootDir: UNC_ROOT, files: [] })
expect(issues).toHaveLength(names.length)
expect(issues.every((issue) => issue.path.startsWith(UNC_ROOT))).toBe(true)
}
})
})
+30 -9
View File
@@ -1,8 +1,9 @@
import type { Dirent } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import { extname, join } from 'node:path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
@@ -15,15 +16,31 @@ export async function discoverFiles(args: {
filePredicate?: (path: string) => boolean
directoryPredicate?: (name: string, depth: number) => boolean
}): Promise<SessionFileDiscovery> {
const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
})
let paths: string[]
try {
paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
})
} catch (err) {
// Why: discoverAiVaultSessionSources fans out with Promise.all, so one
// stalled distro would otherwise reject the whole vault scan — including
// every healthy local agent. Contain it to this root.
if (!(err instanceof WslTranscriptFsError)) {
throw err
}
recordSessionScanIssue(args.issues, {
agent: args.agent,
path: args.rootDir,
message: err.message
})
return { agent: args.agent, rootDir: args.rootDir, files: [] }
}
const files: FileWithMtime[] = []
for (const path of paths) {
try {
const fileStat = await stat(path)
const fileStat = await wslGatedStat(path, 'scan')
files.push({
path,
mtimeMs: fileStat.mtimeMs,
@@ -34,7 +51,11 @@ export async function discoverFiles(args: {
nlink: fileStat.nlink
})
} catch (err) {
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
recordSessionScanIssue(args.issues, {
agent: args.agent,
path,
message: errorMessage(err)
})
}
}
return {
@@ -64,7 +85,7 @@ export async function walkSessionFiles(
try {
entries = options.readDirectory
? await options.readDirectory(dirPath)
: await readdir(dirPath, { withFileTypes: true })
: await wslGatedReaddir(dirPath, 'scan', options.signal)
} catch (error) {
options.signal?.throwIfAborted()
// Why: a gate refusal means the scan could not run, not that the tree is
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
@@ -34,11 +34,16 @@ export async function parseDroidSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseDroidSessionLines({ file, lines, platform })
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseDroidSessionLines({ file, lines, platform })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
export async function parseDroidSessionContent(
@@ -0,0 +1,81 @@
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const AI_VAULT_DIR = join(import.meta.dirname, '.')
const NATIVE_CHAT_DIR = join(import.meta.dirname, '..', 'native-chat')
// Transcript I/O in these modules must go through the WSL admission gate, so a
// stalled 9P mount cannot hang a scan or a Native Chat load (STA-4049).
const NATIVE_CHAT_MODULES = [
'transcript-read-cache.ts',
'transcript-reader.ts',
'transcript-tail-reader.ts',
'transcript-file-version.ts',
'transcript-incremental-reader.ts',
'transcript-watch-engine.ts'
]
// Adding to this list must be a reviewed act: each entry names why the file
// touches a path that can never be a WSL UNC transcript.
const ALLOWLIST = new Set([
// `existsSync` on bundled module paths inside the app dir.
'session-scanner-service-entry-path.ts',
'session-scanner-service-spawn.ts',
'session-scanner-worker-spawn.ts',
'session-scanner-opencode-sqlite-worker-spawn.ts',
// On-demand IPC readers, gated in the STA-4049 follow-up.
'session-scanner-claude-subagents.ts',
'session-scanner-omp-subagent-listing.ts',
// Test-only fixture builder.
'session-scanner-test-fixtures.ts'
])
const FS_IMPORT = /import\s+([\s\S]*?)\s+from\s+['"]node:fs(?:\/promises)?['"]/g
function valueImportsOfNodeFs(source: string): string[] {
return [...source.matchAll(FS_IMPORT)]
.map((match) => match[1]!.trim())
.filter((clause) => !clause.startsWith('type'))
}
async function guardedFiles(): Promise<{ name: string; path: string }[]> {
const aiVault = (await readdir(AI_VAULT_DIR))
.filter((name) => name.startsWith('session-scanner') || name === 'session-title-file-reader.ts')
.filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts'))
.filter((name) => !ALLOWLIST.has(name))
.map((name) => ({ name, path: join(AI_VAULT_DIR, name) }))
return [
...aiVault,
...NATIVE_CHAT_MODULES.map((name) => ({ name, path: join(NATIVE_CHAT_DIR, name) }))
]
}
describe('WSL transcript gate import guard', () => {
it('leaves no raw node:fs value import in a gated transcript module', async () => {
const files = await guardedFiles()
const offenders: string[] = []
for (const file of files) {
for (const clause of valueImportsOfNodeFs(await readFile(file.path, 'utf-8'))) {
offenders.push(`${file.name}: import ${clause} from 'node:fs…'`)
}
}
expect(offenders).toEqual([])
})
it('actually covers the modules it claims to', async () => {
const names = (await guardedFiles()).map((file) => file.name)
expect(names).toEqual(expect.arrayContaining(NATIVE_CHAT_MODULES))
// The V12–V17 sites the previous `*parser*` wording silently skipped.
expect(names).toEqual(
expect.arrayContaining([
'session-title-file-reader.ts',
'session-scanner.ts',
'session-scanner-values.ts',
'session-scanner-codex-title-index.ts',
'session-scanner-kimi-paths.ts',
'session-scanner-opencode-sources.ts'
])
)
})
})
@@ -1,6 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type {
@@ -34,7 +33,11 @@ export async function parseGeminiSessionFile(
return parseGeminiJsonlSessionFile(file, platform)
}
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
return parseGeminiJsonSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
)
}
export async function parseGeminiSessionContent(
@@ -83,7 +86,7 @@ export async function parseGeminiJsonlSessionFile(
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
@@ -1,6 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@@ -41,7 +40,9 @@ export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
const metadata = asRecord(
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
)
if (!metadata) {
return null
}
@@ -175,11 +176,16 @@ export async function parseMessageGraphSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseMessageGraphSessionLines({ agent, file, lines, platform })
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseMessageGraphSessionLines({ agent, file, lines, platform })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
export async function parseMessageGraphSessionContent(
@@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@@ -36,7 +36,7 @@ export async function parseGrokSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
return null
}
@@ -62,12 +62,13 @@ async function consumeGrokChatHistory(
accumulator: SessionAccumulator,
sessionDir: string
): Promise<void> {
const input = openTranscriptReadStream(
join(sessionDir, 'chat_history.jsonl'),
{ encoding: 'utf-8' },
'scan'
)
const lines = createInterface({ input, crlfDelay: Infinity })
try {
const lines = createInterface({
input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
@@ -111,8 +112,18 @@ async function consumeGrokChatHistory(
seedFirstUserPrompt: false
})
}
} catch {
// Summary-only sessions still provide enough metadata for the Vault list.
} catch (error) {
// Summary-only sessions still provide enough metadata for the Vault list. A
// gate refusal is a different thing — the history exists but is unreachable,
// so a message-less session must not be cached under the summary's mtime.
if (error instanceof WslTranscriptFsError) {
throw error
}
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-read failure cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
}
@@ -1,4 +1,4 @@
import { readFile } from 'node:fs/promises'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
@@ -26,7 +26,11 @@ export async function parseHermesSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
return parseHermesSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
)
}
export async function parseHermesSessionContent(
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const CODEX_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex'
const CODEX_SESSION_FILE = `${CODEX_HOME}\\sessions\\2026\\01\\01\\rollout-1.jsonl`
const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const KIMI_INDEX = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.kimi\\session_index.jsonl'
const mocks = vi.hoisted(() => ({ stat: vi.fn(), open: vi.fn(), readFile: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
stat: mocks.stat,
open: mocks.open,
readFile: mocks.readFile
}))
import {
readCodexSessionIndexTitle,
resetCodexSessionIndexTitleCacheForTests,
_hasCodexSessionIndexTitleCacheEntryForTest
} from './session-scanner-codex-title-index'
import {
clearKimiSessionIndexCache,
hasKimiSessionIndexCacheEntryForTests,
readKimiWorkDirBySessionId
} from './session-scanner-kimi-paths'
import { readJsonObjectIfExists } from './session-scanner-values'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
// Identity must not change between the refused and the recovered read, or the
// cache would miss for that reason instead of because the entry was evicted.
const INDEX_STATS = { size: 128, mtimeMs: 7, ctimeMs: 7 }
let releaseStall: (() => void) | undefined
function stalls(): Promise<never> {
return new Promise<never>((resolve) => {
releaseStall = () => resolve(undefined as never)
})
}
function servingHandle(body: string) {
const bytes = Buffer.from(body)
return {
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = bytes.subarray(position, Math.min(position + length, bytes.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}),
close: vi.fn(async () => {})
}
}
async function releaseAndSettle(): Promise<void> {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
resetCodexSessionIndexTitleCacheForTests()
clearKimiSessionIndexCache()
mocks.stat.mockReset()
mocks.open.mockReset()
mocks.readFile.mockReset()
mocks.stat.mockResolvedValue(INDEX_STATS)
releaseStall = undefined
vi.useFakeTimers()
})
afterEach(async () => {
await releaseAndSettle()
vi.useRealTimers()
})
describe('memoized WSL session indexes under a stalled mount', () => {
it('evicts the Codex title index instead of pinning "no titles"', async () => {
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
const refused = readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
expect(await refused).toBeNull()
expect(_hasCodexSessionIndexTitleCacheEntryForTest(CODEX_HOME)).toBe(false)
await releaseAndSettle()
mocks.open.mockResolvedValue(
servingHandle(`${JSON.stringify({ id: SESSION_ID, thread_name: 'Ship the gate' })}\n`)
)
// Same size/mtime as the refused read: only the eviction can produce a hit.
expect(await readCodexSessionIndexTitle(CODEX_SESSION_FILE, CODEX_HOME, SESSION_ID)).toBe(
'Ship the gate'
)
})
it('evicts the Kimi work-dir index instead of pinning "no cwd"', async () => {
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
const refused = readKimiWorkDirBySessionId(KIMI_INDEX)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
expect(await refused).toEqual(new Map())
expect(hasKimiSessionIndexCacheEntryForTests(KIMI_INDEX)).toBe(false)
await releaseAndSettle()
mocks.open.mockResolvedValue(
servingHandle(`${JSON.stringify({ sessionId: SESSION_ID, workDir: '/repo/app' })}\n`)
)
expect(await readKimiWorkDirBySessionId(KIMI_INDEX)).toEqual(
new Map([[SESSION_ID, '/repo/app']])
)
})
it('surfaces a refused optional JSON enrichment instead of caching it as absent', async () => {
mocks.readFile.mockImplementation(stalls)
const pending = readJsonObjectIfExists(`${CODEX_HOME}\\history.json`).catch(
(error: unknown) => error
)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
// A throw, not `null`: `parseSessionCandidate` turns it into a scan issue,
// where `null` would cache an un-enriched session that never re-reads.
expect(await pending).toBeInstanceOf(WslTranscriptFsError)
})
it('still degrades a missing optional JSON file to null', async () => {
mocks.readFile.mockRejectedValue(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' as const })
)
await expect(readJsonObjectIfExists(`${CODEX_HOME}\\missing.json`)).resolves.toBeNull()
})
})
@@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import {
@@ -36,8 +36,16 @@ export async function parseKimiSessionFile(
): Promise<AiVaultSession | null> {
let stateRecord: Record<string, unknown> | null
try {
stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
} catch {
stateRecord = asRecord(
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
)
} catch (error) {
// Why: a missing/half-written state file is genuinely "no session", but a
// gate refusal is not — returning null would let the parse cache store that
// degraded answer under the file's unchanged mtime and never retry.
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
if (!stateRecord) {
@@ -83,11 +91,9 @@ async function consumeKimiWireTranscript(
}
}
const input = openTranscriptReadStream(wirePath, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
const lines = createInterface({
input: createReadStream(wirePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const record = parseJsonObject(line)
if (!record) {
@@ -111,9 +117,18 @@ async function consumeKimiWireTranscript(
break
}
}
} catch {
} catch (error) {
// No transcript yet (session created but never ran a turn) — metadata-only
// sessions still belong in the panel.
// sessions still belong in the panel. A gate refusal instead means the wire
// bytes exist but are unreachable; a partial session must not be cached.
if (error instanceof WslTranscriptFsError) {
throw error
}
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-read failure cannot leak the gated transcript handle.
lines.close()
input.destroy()
}
flushAssistant()
}
@@ -1,8 +1,8 @@
import { stat } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { asRecord, extractString } from './session-scanner-values'
import {
KimiSessionIndexCache,
@@ -78,9 +78,9 @@ export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolea
export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map<string, string>> {
const generation = workDirCacheByIndexPath.beginRead()
let identity: Awaited<ReturnType<typeof stat>>
let identity: Awaited<ReturnType<typeof wslGatedStat>>
try {
identity = await stat(indexPath)
identity = await wslGatedStat(indexPath, 'scan')
} catch {
// Missing index (e.g. user deleted it): sessions still list, just without cwd.
workDirCacheByIndexPath.delete(indexPath, generation)
@@ -95,18 +95,30 @@ export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map
sizeBytes: identity.size
},
generation,
() => parseKimiSessionIndex(indexPath)
() =>
parseKimiSessionIndex(indexPath).then(({ map, refused }) => {
// Why: a gate refusal is a stalled distro, not a cwd-less index — evict
// so the partial map is not served until the index's identity changes.
// Inside the loader, not at the call site: only the caller that missed
// runs this, so a concurrent hit would otherwise serve and keep it.
if (refused) {
workDirCacheByIndexPath.delete(indexPath, generation)
}
return map
})
)
}
async function parseKimiSessionIndex(indexPath: string): Promise<Map<string, string>> {
async function parseKimiSessionIndex(
indexPath: string
): Promise<{ map: Map<string, string>; refused: boolean }> {
const map = new Map<string, string>()
// Why: never reject. This promise is memoized and shared by every session
// under one Kimi home; a mid-read failure (file deleted after stat, EACCES)
// must degrade to whatever was parsed so the other sessions still list.
try {
const lines = createInterface({
input: createReadStream(indexPath, { encoding: 'utf-8' }),
input: openTranscriptReadStream(indexPath, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
for await (const line of lines) {
@@ -126,8 +138,9 @@ async function parseKimiSessionIndex(indexPath: string): Promise<Map<string, str
map.set(sessionId, workDir)
}
}
} catch {
} catch (error) {
// Return the partial map gathered before the read error.
return { map, refused: error instanceof WslTranscriptFsError }
}
return map
return { map, refused: false }
}
@@ -1,10 +1,12 @@
import { readdir, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import type {
AiVaultScanIssue,
AiVaultSession,
AiVaultSubagentListResult
} from '../../shared/ai-vault-types'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers'
import {
@@ -16,6 +18,8 @@ import { errorMessage } from './session-scanner-values'
// Match the Claude subagent lister's deliberate parse batching: opening every
// read stream at once stalls over WSL UNC paths.
const OMP_SUBAGENT_PARSE_CONCURRENCY = 8
// Bulk directory work, so 'scan' — same reasoning as the Claude lister.
const OMP_SUBAGENT_FS_PRIORITY = 'scan'
/**
* List the task-subagent transcripts of one OMP session, on demand. The main
@@ -35,8 +39,13 @@ export async function listOmpSubagentSessions(args: {
let entries
try {
entries = await readdir(artifactDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(artifactDir, OMP_SUBAGENT_FS_PRIORITY)
} catch (err) {
// A gate refusal is a stalled distro, not a session without subagents —
// report it so the panel offers a retry instead of showing an empty list.
if (err instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, { agent: 'omp', path: artifactDir, message: err.message })
}
return { sessions: [], issues }
}
@@ -79,7 +88,7 @@ async function parseOmpSubagentTranscript(args: {
}): Promise<AiVaultSession | null> {
const filePath = join(args.artifactDir, args.name)
try {
const fileStat = await stat(filePath)
const fileStat = await wslGatedStat(filePath, OMP_SUBAGENT_FS_PRIORITY)
// The shared OMP parser decorates every parse with an artifact-dir count, so
// a child row carries its own grandchild count. It is accurate but has no
// renderer — subagent rows don't expand — and this lister is local-only, so
@@ -107,7 +116,11 @@ async function parseOmpSubagentTranscript(args: {
}
}
} catch (err) {
args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) })
recordSessionScanIssue(args.issues, {
agent: 'omp',
path: filePath,
message: errorMessage(err)
})
return null
}
}
@@ -1,4 +1,4 @@
import { readdir } from 'node:fs/promises'
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
import { basename, dirname, extname, join } from 'node:path'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts'
@@ -51,7 +51,7 @@ export function isOmpSubagentTranscriptFileName(name: string, isFile: boolean):
export async function countOmpSubagentTranscripts(transcriptFilePath: string): Promise<number> {
let entries
try {
entries = await readdir(ompArtifactDirFor(transcriptFilePath), { withFileTypes: true })
entries = await wslGatedReaddir(ompArtifactDirFor(transcriptFilePath), 'scan')
} catch {
return 0
}
@@ -1,4 +1,5 @@
import { readFile, readdir } from 'node:fs/promises'
import { wslGatedReaddir, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { join } from 'node:path'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
@@ -26,7 +27,7 @@ export async function parseOpenCodeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
return null
}
@@ -47,8 +48,13 @@ export async function parseOpenCodeSessionFile(
async function readOpenCodeMessagesInOrder(messageDir: string): Promise<Record<string, unknown>[]> {
let entries
try {
entries = await readdir(messageDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(messageDir, 'scan')
} catch (error) {
// A session with no message dir yet is empty; a gate refusal is not — an
// empty transcript would otherwise be cached under the session's mtime.
if (error instanceof WslTranscriptFsError) {
throw error
}
return []
}
const messages: { name: string; createdMs: number; message: Record<string, unknown> }[] = []
@@ -59,11 +65,15 @@ async function readOpenCodeMessagesInOrder(messageDir: string): Promise<Record<s
let message: Record<string, unknown> | null = null
try {
message = asRecord(
JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown
JSON.parse(await wslGatedReadFile(join(messageDir, entry.name), 'utf-8', 'scan')) as unknown
)
} catch {
} catch (error) {
// A live OpenCode process can leave a half-written file; skip it rather
// than discard the whole session.
// than discard the whole session. A refused read is not a bad file — the
// partial transcript it would produce must not become the cached answer.
if (error instanceof WslTranscriptFsError) {
throw error
}
continue
}
if (!message) {
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Dirent } from 'node:fs'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
const WSL_DATA_DIR = `${WSL_HOME}/.local/share/opencode`
const mocks = vi.hoisted(() => ({ readdir: vi.fn(), stat: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
readdir: mocks.readdir,
stat: mocks.stat
}))
// The SQLite leg spawns a real worker thread, which fake timers cannot drive.
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({
listOpenCodeSqliteSessionsViaWorker: async () => []
}))
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate'
let releaseStall: (() => void) | undefined
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
function stalls(): Promise<never> {
return new Promise<never>((resolve) => {
releaseStall = () => resolve(undefined as never)
})
}
beforeEach(() => {
mocks.readdir.mockReset()
mocks.stat.mockReset()
releaseStall = undefined
vi.useFakeTimers()
})
afterEach(async () => {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
})
describe('OpenCode source discovery with a stalled WSL data directory', () => {
it('reports the stalled home as an issue and still resolves every discovery', async () => {
mocks.readdir.mockImplementation((dir: string) =>
dir === WSL_DATA_DIR ? stalls() : Promise.resolve([] as Dirent[])
)
const issues: AiVaultScanIssue[] = []
const discoveries = Promise.all(
opencodeDiscoveries(
{ opencodeStorageDir: '/home/ada/.local/share/opencode/storage' },
[WSL_HOME],
10,
issues
)
)
// Two deadlines: the data-dir probe, then the storage scan that queued
// behind it before the route was flagged stuck.
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
// Zero databases for that home is the degraded answer; without the issue it
// would be indistinguishable from "OpenCode was never installed there".
const resolved = await discoveries
expect(resolved).toHaveLength(2)
expect(resolved.every((discovery) => discovery.files.length === 0)).toBe(true)
expect(issues.some((issue) => issue.path === WSL_DATA_DIR)).toBe(true)
expect(issues.every((issue) => issue.agent === 'opencode')).toBe(true)
})
it('reports a refused primary data directory instead of scanning clean', async () => {
const previousXdg = process.env.XDG_DATA_HOME
const previousDb = process.env.OPENCODE_DB
process.env.XDG_DATA_HOME = WSL_HOME
delete process.env.OPENCODE_DB
try {
mocks.readdir.mockImplementation(stalls)
const issues: AiVaultScanIssue[] = []
const discoveries = Promise.all(opencodeDiscoveries({}, [], 10, issues))
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS * 2 + 2)
// The primary source is the one per-root containment cannot reach, so a
// silent [] here reads as "no OpenCode sessions" on a clean scan.
await expect(discoveries).resolves.toHaveLength(1)
expect(
issues.some((issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode`)
).toBe(true)
} finally {
restoreEnv('XDG_DATA_HOME', previousXdg)
restoreEnv('OPENCODE_DB', previousDb)
}
})
})
@@ -1,8 +1,10 @@
import { readdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory'
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
@@ -16,7 +18,7 @@ export function opencodeDiscoveries(
return storageDirs.map(async (storageDir, index) =>
discoverOpenCodeSessions({
storageDir,
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index),
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues),
limitPerAgent: limit,
issues
})
@@ -37,32 +39,47 @@ async function opencodeDbPathsForSource(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
storageDir: string,
sourceIndex: number
sourceIndex: number,
issues: AiVaultScanIssue[]
): Promise<readonly string[]> {
if (options.opencodeDbPaths) {
return sourceIndex === 0 ? options.opencodeDbPaths : []
}
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
if (sourceIndex === 0 && options.opencodeStorageDir) {
return listOpenCodeDatabasesInDirectory(dirname(storageDir))
return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues)
}
if (sourceIndex === 0) {
return listOpenCodeDatabases()
return listOpenCodeDatabases((path, error) => {
recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message })
})
}
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
return wslHomeDir
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'))
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues)
: []
}
async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise<string[]> {
async function listOpenCodeDatabasesInDirectory(
dataDir: string,
issues: AiVaultScanIssue[]
): Promise<string[]> {
try {
const entries = await readdir(dataDir, { withFileTypes: true })
const entries = await wslGatedReaddir(dataDir, 'scan')
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDir, entry.name))
.sort()
} catch {
} catch (error) {
// A stalled WSL data dir still degrades to "no databases", but the gap has
// to be reportable — an empty list otherwise reads as "OpenCode not used".
if (error instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, {
agent: 'opencode',
path: dataDir,
message: error.message
})
}
return []
}
}
@@ -1,5 +1,7 @@
import { createReadStream } from 'node:fs'
import { open } from 'node:fs/promises'
import {
openTranscriptReadStream,
readTranscriptSlice
} from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
@@ -305,13 +307,8 @@ async function parseResumableCandidate(args: {
// agent transcripts are append-only so that trade is accepted (worst case is
// a stale vault row until the file is next truncated or the app restarts).
async function endsWithNewlineAt(path: string, offset: number): Promise<boolean> {
const handle = await open(path, 'r')
try {
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1), 0, 1, offset - 1)
return bytesRead === 1 && buffer[0] === NEWLINE_BYTE
} finally {
await handle.close()
}
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
return slice.length === 1 && slice[0] === NEWLINE_BYTE
}
type JsonlReadResult = {
@@ -336,7 +333,7 @@ async function consumeCompleteJsonlLines(args: {
let remainderParts: Buffer[] = []
let remainderLength = 0
const stream = createReadStream(args.path, { start: args.start })
const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan')
for await (const chunk of stream as AsyncIterable<Buffer>) {
bytesRead += chunk.length
// Why check the chunk alone: the pieces held over are all mid-line, so none
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
import type { SessionFileCandidate } from './session-scanner-types'
const STALLED_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\a.jsonl'
const SIBLING_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.claude\\projects\\p\\b.jsonl'
const mocks = vi.hoisted(() => ({ open: vi.fn(), readdir: vi.fn() }))
// readdir too: every claude parse counts sibling subagent transcripts, and an
// unmocked one would reach the host UNC path and stall under fake timers.
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof NodeFsPromisesModule>()),
open: mocks.open,
readdir: mocks.readdir
}))
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from './session-scanner-parse-cache'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from '../native-chat/wsl-transcript-fs-gate'
type ReadResult = { bytesRead: number; buffer: Buffer }
// A stalled task keeps the gate's single scan slot until the call settles, so
// every case releases its stall before the next one runs.
let releaseStall: (() => void) | undefined
function stalls(): Promise<ReadResult> {
return new Promise<ReadResult>((resolve) => {
releaseStall = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) })
})
}
function servingHandle(body: Buffer) {
return {
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const slice = body.subarray(position, Math.min(position + length, body.length))
slice.copy(buffer, offset)
return { bytesRead: slice.length, buffer }
}),
close: vi.fn(async () => {})
}
}
function record(index: number, text: string): string {
return JSON.stringify({
type: 'user',
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
timestamp: new Date(1740000000000 + index * 60_000).toISOString(),
cwd: '/repo/app',
gitBranch: 'main',
message: { role: 'user', content: text }
})
}
function body(count: number): Buffer {
return Buffer.from(
Array.from({ length: count }, (_, index) => `${record(index, `line ${index}`)}\n`).join('')
)
}
function candidate(path: string, bytes: Buffer, mtimeMs: number): SessionFileCandidate {
return {
agent: 'claude',
file: {
path,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: bytes.length
},
codexHome: null
}
}
async function releaseAndSettle(): Promise<void> {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
}
beforeEach(() => {
resetSessionParseCacheForTests()
mocks.open.mockReset()
mocks.readdir.mockReset()
mocks.readdir.mockResolvedValue([])
releaseStall = undefined
vi.useFakeTimers()
})
afterEach(async () => {
await releaseAndSettle()
vi.useRealTimers()
})
describe('AI Vault session parse with a stalled WSL transcript body read', () => {
it('refuses the stalled candidate and still parses a sibling on a healthy distro', async () => {
mocks.open.mockResolvedValue({ read: vi.fn(stalls), close: vi.fn(async () => {}) })
// Rejecting (not resolving null) is what lets `parseSessionCandidate`
// report it as an issue instead of silently dropping the session. The
// assertion is attached before the clock moves so the rejection is never
// momentarily unhandled.
const refusal = expect(
parseAgentSessionFileCached(candidate(STALLED_PATH, body(2), 1), 'linux')
).rejects.toBeInstanceOf(WslTranscriptFsError)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await refusal
await releaseAndSettle()
const siblingBytes = body(2)
mocks.open.mockResolvedValue(servingHandle(siblingBytes))
const sibling = await parseAgentSessionFileCached(
candidate(SIBLING_PATH, siblingBytes, 1),
'linux'
)
expect(sibling?.messageCount).toBe(2)
})
it('caches no resume point when the newline probe is refused mid-append', async () => {
const first = body(2)
mocks.open.mockResolvedValue(servingHandle(first))
const seeded = await parseAgentSessionFileCached(candidate(STALLED_PATH, first, 1), 'linux')
expect(seeded?.messageCount).toBe(2)
const grown = body(3)
const serving = servingHandle(grown)
mocks.open.mockResolvedValue({
// Only the 1-byte resume probe stalls; the body read stays healthy.
read: vi.fn((buffer: Buffer, offset: number, length: number, position: number) =>
length === 1 ? stalls() : serving.read(buffer, offset, length, position)
),
close: vi.fn(async () => {})
})
const refused = expect(
parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
).rejects.toBeInstanceOf(WslTranscriptFsError)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
await refused
await releaseAndSettle()
mocks.open.mockResolvedValue(servingHandle(grown))
const recovered = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
resetSessionParseCacheForTests()
const cold = await parseAgentSessionFileCached(candidate(STALLED_PATH, grown, 2), 'linux')
expect(recovered).toEqual(cold)
expect(recovered?.messageCount).toBe(3)
})
})
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Readable } from 'node:stream'
import type * as NodeReadlineModule from 'node:readline'
import type * as WslFsAccessModule from '../native-chat/wsl-transcript-fs-access'
import type { FileWithMtime } from './session-scanner-types'
// readline.close() leaves its input open, so a parser that stops consuming
// mid-file (a throw, a break) strands the gated transcript handle until the
// gate's deadline and delays every later scan. These pin the finally blocks.
const mocks = vi.hoisted(() => ({
openStream: vi.fn(),
readFile: vi.fn(),
stat: vi.fn(),
createInterface: vi.fn()
}))
vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => ({
...(await importOriginal<typeof WslFsAccessModule>()),
openTranscriptReadStream: mocks.openStream,
wslGatedReadFile: mocks.readFile,
wslGatedStat: mocks.stat
}))
vi.mock('node:readline', async (importOriginal) => ({
...(await importOriginal<typeof NodeReadlineModule>()),
createInterface: mocks.createInterface
}))
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
import { parseDroidSessionFile } from './session-scanner-droid-parser'
import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths'
const PARSE_FAILURE = 'parser failed mid-transcript'
const opened: { path: string; stream: Readable }[] = []
const interfaces: { close: ReturnType<typeof vi.fn> }[] = []
function file(path: string): FileWithMtime {
return { path, mtimeMs: 1, modifiedAt: '2026-06-01T10:05:00.000Z', sizeBytes: 128 }
}
function lastOpened(): { path: string; stream: Readable } {
const entry = opened.at(-1)
if (!entry) {
throw new Error('no transcript stream was opened')
}
return entry
}
function expectStreamTornDown(): void {
expect(lastOpened().stream.destroyed).toBe(true)
expect(interfaces.at(-1)?.close).toHaveBeenCalled()
}
beforeEach(() => {
opened.length = 0
interfaces.length = 0
clearKimiSessionIndexCache()
mocks.openStream.mockReset()
mocks.readFile.mockReset()
mocks.stat.mockReset()
mocks.createInterface.mockReset()
mocks.openStream.mockImplementation((path: string) => {
const stream = new Readable({
read() {
this.push(null)
}
})
opened.push({ path, stream })
return stream
})
// One line, then a consumer-side throw: the parser must still tear the
// stream down on its way out.
mocks.createInterface.mockImplementation(() => {
const lines = {
close: vi.fn(),
[Symbol.asyncIterator]: async function* () {
yield '{}'
throw new Error(PARSE_FAILURE)
}
}
interfaces.push(lines)
return lines
})
})
describe('session parsers that stop consuming a gated transcript early', () => {
it.each([
['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')],
['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')],
['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')]
])('destroys the stream when the %s parse throws', async (_agent, parse) => {
await expect(parse()).rejects.toThrow(PARSE_FAILURE)
expectStreamTornDown()
})
it('destroys the chat_history stream when the Grok parse swallows the failure', async () => {
mocks.readFile.mockResolvedValue(JSON.stringify({ info: { id: 'ses-1' } }))
// Grok degrades to a summary-only session on a non-gate failure, so the
// teardown has no rejection to ride out on.
await expect(
parseGrokSessionFile(file('/w/.grok/sessions/ses-1/session.json'))
).resolves.toBeTruthy()
expect(lastOpened().path).toContain('chat_history.jsonl')
expectStreamTornDown()
})
it('destroys the wire stream when the Kimi parse swallows the failure', async () => {
mocks.readFile.mockResolvedValue(JSON.stringify({ title: 'Kimi session' }))
// No session_index.jsonl, so only the wire transcript opens a stream.
mocks.stat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
await expect(
parseKimiSessionFile(file('/w/.kimi-code/sessions/wd_app/session_abc/state.json'))
).resolves.toBeTruthy()
expect(lastOpened().path).toContain('wire.jsonl')
expectStreamTornDown()
})
})
@@ -1,5 +1,5 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
@@ -210,7 +210,7 @@ export async function parseClaudeSessionFile(
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseClaudeSessionLines({ file, lines, platform })
@@ -1,13 +1,18 @@
import { createReadStream } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import { extname, join } from 'node:path'
import {
openTranscriptReadStream,
wslGatedReaddir,
wslGatedStat
} from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import {
isPathInsideOrEqual,
normalizeRuntimePathSeparators
} from '../../shared/cross-platform-path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { recordSessionScanIssue } from './session-scan-issues'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
@@ -26,7 +31,18 @@ export function resetProjectDirCwdCacheForTests(): void {
projectDirCwdCache.clear()
}
async function cachedProjectDirCwd(projectDir: string): Promise<string | null> {
// A gate refusal is a stalled WSL root, not an empty one: keep the existing
// degrade-to-empty containment but make the gap visible in the scan issues.
function recordRefusal(issues: AiVaultScanIssue[], path: string, error: unknown): void {
if (error instanceof WslTranscriptFsError) {
recordSessionScanIssue(issues, { agent: 'claude', path, message: error.message })
}
}
async function cachedProjectDirCwd(
projectDir: string,
issues: AiVaultScanIssue[]
): Promise<string | null> {
const cached = projectDirCwdCache.get(projectDir)
if (cached !== undefined) {
// Refresh recency so hot in-scope dirs outlive one-off ones at the cap.
@@ -34,7 +50,7 @@ async function cachedProjectDirCwd(projectDir: string): Promise<string | null> {
projectDirCwdCache.set(projectDir, cached)
return cached
}
const cwd = await readProjectDirCwd(projectDir)
const cwd = await readProjectDirCwd(projectDir, issues)
if (cwd) {
if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) {
const oldest = projectDirCwdCache.keys().next()
@@ -70,8 +86,8 @@ export async function discoverInScopeClaudeFiles(args: {
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
const collected = new Map<string, FileWithMtime>()
for (const rootDir of args.rootDirs) {
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
const cwd = await cachedProjectDirCwd(projectDir)
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes, args.issues)) {
const cwd = await cachedProjectDirCwd(projectDir, args.issues)
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
continue
}
@@ -148,12 +164,14 @@ function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
async function listProjectDirs(
rootDir: string,
scopeProjectPrefixes: ReadonlySet<string>
scopeProjectPrefixes: ReadonlySet<string>,
issues: AiVaultScanIssue[]
): Promise<string[]> {
let entries
try {
entries = await readdir(rootDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(rootDir, 'scan')
} catch (err) {
recordRefusal(issues, rootDir, err)
return []
}
return entries
@@ -163,10 +181,13 @@ async function listProjectDirs(
.map((entry) => join(rootDir, entry.name))
}
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir)
async function readProjectDirCwd(
projectDir: string,
issues: AiVaultScanIssue[]
): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir, issues)
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
const cwd = await readFirstCwd(file)
const cwd = await readFirstCwd(file, issues)
if (cwd) {
return cwd
}
@@ -174,11 +195,15 @@ async function readProjectDirCwd(projectDir: string): Promise<string | null> {
return null
}
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
async function newestClaudeFilesInDir(
projectDir: string,
issues: AiVaultScanIssue[]
): Promise<string[]> {
let entries
try {
entries = await readdir(projectDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(projectDir, 'scan')
} catch (err) {
recordRefusal(issues, projectDir, err)
return []
}
const newest: { path: string; mtimeMs: number }[] = []
@@ -190,18 +215,19 @@ async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
try {
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
path,
mtimeMs: (await stat(path)).mtimeMs
mtimeMs: (await wslGatedStat(path, 'scan')).mtimeMs
})
} catch {
} catch (err) {
// Best effort: unreadable candidates are ignored here and reported during
// full collection if the project directory proves in-scope.
recordRefusal(issues, path, err)
}
}
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
}
async function readFirstCwd(filePath: string): Promise<string | null> {
const input = createReadStream(filePath, { encoding: 'utf-8' })
async function readFirstCwd(filePath: string, issues: AiVaultScanIssue[]): Promise<string | null> {
const input = openTranscriptReadStream(filePath, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
let read = 0
try {
@@ -214,7 +240,8 @@ async function readFirstCwd(filePath: string): Promise<string | null> {
return cwd
}
}
} catch {
} catch (err) {
recordRefusal(issues, filePath, err)
return null
} finally {
// readline.close() leaves the underlying stream open; destroy it so the early
@@ -234,8 +261,9 @@ async function collectClaudeFiles(args: {
}): Promise<void> {
let entries
try {
entries = await readdir(args.projectDir, { withFileTypes: true })
} catch {
entries = await wslGatedReaddir(args.projectDir, 'scan')
} catch (err) {
recordRefusal(args.issues, args.projectDir, err)
return
}
for (const entry of entries) {
@@ -247,7 +275,7 @@ async function collectClaudeFiles(args: {
continue
}
try {
const fileStat = await stat(path)
const fileStat = await wslGatedStat(path, 'scan')
addBoundedFile(args.collected, args.limit, {
path,
mtimeMs: fileStat.mtimeMs,
@@ -255,7 +283,7 @@ async function collectClaudeFiles(args: {
sizeBytes: fileStat.size
})
} catch (err) {
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
recordSessionScanIssue(args.issues, { agent: 'claude', path, message: errorMessage(err) })
}
}
}
@@ -1,4 +1,4 @@
import { readdir } from 'node:fs/promises'
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
import { basename, dirname, extname, join } from 'node:path'
// Exported so discovery can prune these subtrees using the same literal that
@@ -38,7 +38,7 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
export async function countSubagentTranscripts(transcriptFilePath: string): Promise<number> {
let entries
try {
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true })
entries = await wslGatedReaddir(subagentTranscriptsDirFor(transcriptFilePath), 'scan')
} catch {
return 0
}
+11 -3
View File
@@ -1,6 +1,7 @@
import { homedir } from 'node:os'
import { basename, dirname, isAbsolute, join } from 'node:path'
import { readFile } from 'node:fs/promises'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { asRecord } from './session-scanner-record-value'
export { asRecord }
@@ -76,8 +77,15 @@ export async function readJsonObjectIfExists(
filePath: string
): Promise<Record<string, unknown> | null> {
try {
return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown)
} catch {
return asRecord(JSON.parse(await wslGatedReadFile(filePath, 'utf-8', 'scan')) as unknown)
} catch (error) {
// A missing or malformed file is genuinely "no enrichment", but a gate
// refusal must reach `parseSessionCandidate` as an issue — degrading it to
// null caches the un-enriched session under an unchanged mtime, and the
// non-resumable agents that use this never re-read it.
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
}
+6 -11
View File
@@ -1,4 +1,3 @@
import { readFile } from 'node:fs/promises'
import type {
AiVaultListResult,
AiVaultScanIssue,
@@ -14,6 +13,7 @@ import {
} from './codex-session-root-dedup'
import {
createAntigravityWorkspaceResolver,
readLocalAntigravityHistory,
type AntigravityWorkspaceResolver
} from './session-scanner-antigravity-history'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
@@ -27,6 +27,7 @@ import {
parseAgentSessionFileCached,
type SessionParseStats
} from './session-scanner-parse-cache'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
@@ -71,7 +72,9 @@ export async function scanAiVaultSessions(
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile)
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
throwIfAiVaultScanCancelled(options.signal)
@@ -255,7 +258,7 @@ async function parseSessionCandidates(args: {
for (const result of results) {
if (result.issue) {
args.issues.push(result.issue)
recordSessionScanIssue(args.issues, result.issue)
}
if (result.session) {
sessions.push(result.session)
@@ -305,14 +308,6 @@ async function parseSessionCandidate(
}
}
async function readOptionalTextFile(path: string): Promise<string | null> {
try {
return await readFile(path, 'utf-8')
} catch {
return null
}
}
function withSessionExecutionHost(
session: AiVaultSession,
executionHostId: ExecutionHostId
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromisesModule from 'node:fs/promises'
const UNC_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\p\\stalled.jsonl'
const STALLED_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const LOCAL_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff'
const mocks = vi.hoisted(() => ({ lstat: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => {
const original = await importOriginal<typeof NodeFsPromisesModule>()
return {
...original,
// Only the WSL request is faked; the local sibling reads a real temp file.
lstat: (path: string, ...rest: unknown[]) =>
path.startsWith('\\\\')
? mocks.lstat(path)
: (original.lstat as (...args: unknown[]) => unknown)(path, ...rest)
}
})
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
import { WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS } from '../native-chat/wsl-transcript-fs-gate'
let releaseStall: (() => void) | undefined
let tempRoot: string | undefined
function stalls(): Promise<never> {
return new Promise<never>((resolve) => {
releaseStall = () => resolve(undefined as never)
})
}
function userRecord(sessionId: string, text: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp: new Date(1740000000000).toISOString(),
cwd: '/repo/app',
gitBranch: 'main',
message: { role: 'user', content: text }
})
}
beforeEach(async () => {
resetSessionParseCacheForTests()
mocks.lstat.mockReset()
releaseStall = undefined
tempRoot = await mkdtemp(join(tmpdir(), 'orca-title-stall-'))
})
afterEach(async () => {
releaseStall?.()
releaseStall = undefined
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
tempRoot = undefined
}
})
describe('AI Vault session titles with a stalled WSL transcript lstat', () => {
it('drops only the stalled request and still returns the rest of the batch', async () => {
const localPath = join(tempRoot!, `${LOCAL_SESSION_ID}.jsonl`)
await writeFile(localPath, `${userRecord(LOCAL_SESSION_ID, 'hello world')}\n`)
mocks.lstat.mockImplementation(stalls)
vi.useFakeTimers()
const pending = readAiVaultSessionTitlesFromFiles([
{ agent: 'claude', sessionId: STALLED_SESSION_ID, transcriptPath: UNC_PATH },
{ agent: 'claude', sessionId: LOCAL_SESSION_ID, transcriptPath: localPath }
])
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS + 1)
const { titles } = await pending
expect(titles).toEqual([{ agent: 'claude', sessionId: LOCAL_SESSION_ID, title: 'hello world' }])
})
})
@@ -1,4 +1,4 @@
import { lstat } from 'node:fs/promises'
import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access'
import {
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
type AiVaultSessionTitle,
@@ -27,7 +27,7 @@ async function readOneTitle(
return cache?.get(request) ?? null
}
try {
const stats = await lstat(transcriptPath)
const stats = await wslGatedLstat(transcriptPath, 'scan', signal)
if (!stats.isFile() || signal?.aborted) {
return null
}
+39 -2
View File
@@ -78,6 +78,10 @@ vi.mock('../providers/windows-conpty-process-membership', () => ({
import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
import {
LEGACY_TERMINAL_SHIM_ENV_KEYS,
stripLegacyTerminalShimEnv
} from '../pty/legacy-terminal-shim-dir'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_OPENCODE_CONFIG_DIR',
@@ -1275,6 +1279,35 @@ describe('createPtySubprocess', () => {
expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined()
})
it('does not inherit legacy attribution state from a pre-upgrade daemon', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const saved = Object.fromEntries(
[...LEGACY_TERMINAL_SHIM_ENV_KEYS, 'PATH'].map((key) => [key, process.env[key]])
)
process.env.ORCA_ENABLE_GIT_ATTRIBUTION = '1'
process.env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-terminal-attribution/posix'
process.env.PATH = `/tmp/orca-terminal-attribution/posix${delimiter}/usr/bin`
try {
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.PATH).toBe('/usr/bin')
for (const key of LEGACY_TERMINAL_SHIM_ENV_KEYS) {
expect(env[key]).toBeUndefined()
}
})
it('does not inherit NODE_ENV from the daemon process env', () => {
// Why: a dev-mode Orca forks the daemon with NODE_ENV=development; leaking
// Orca's build mode into user shells breaks `next build` and Vitest.
@@ -1295,7 +1328,9 @@ describe('createPtySubprocess', () => {
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.NODE_ENV).toBeUndefined()
expect(env.PATH).toBe(process.env.PATH)
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(env.PATH).toBe(expectedEnv.PATH)
})
it('keeps an explicitly requested NODE_ENV for daemon PTY shells', () => {
@@ -2207,6 +2242,8 @@ describe('createPtySubprocess', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, 'win32')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
@@ -2218,7 +2255,7 @@ describe('createPtySubprocess', () => {
}
const env = spawnMock.mock.calls.at(-1)![2].env
expect(env.PATH).toBe(process.env.PATH)
expect(env.PATH).toBe(expectedEnv.PATH)
})
it('preserves a duplicated path block supplied by main', () => {
+5
View File
@@ -35,6 +35,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { parseWslPath } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
@@ -629,6 +630,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links.
FORCE_HYPERLINK: '1'
} as Record<string, string>
// Why: an older client may not ask a newly upgraded daemon to delete inherited shim state.
stripLegacyTerminalShimEnv(env, process.platform)
composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent)
deleteRequestedDaemonEnvKeys(env, opts.envToDelete)
if (opts.env?.TERM) {
@@ -834,6 +837,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)]
: undefined
promoteAgentTeamsShimPath(env, requestedPath)
// Why: raw requested PATH promotion runs after the inherited-env scrub.
stripLegacyTerminalShimEnv(env, process.platform)
// Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix.
ensureNodePtySpawnHelperExecutable()
@@ -0,0 +1,37 @@
import type { DiffComment, FolderWorkspace } from '../shared/types'
// Why shape-only: this replaces folder-workspaces.ts's verbatim `Array.isArray(raw.diffComments)` read.
// Filtering members would make the fix itself a new deletion path for user-authored prose.
export function normalizeFolderWorkspaceDiffComments(
value: unknown
): Record<string, DiffComment[]> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const normalized: Record<string, DiffComment[]> = {}
let kept = false
for (const [id, comments] of Object.entries(value)) {
if (!Array.isArray(comments)) {
continue
}
normalized[id] = comments as DiffComment[]
kept = true
}
return kept ? normalized : undefined
}
// Why derive on every write: the map self-GCs, so delete paths need no pruning code.
export function collectFolderWorkspaceDiffComments(
workspaces: readonly FolderWorkspace[] | undefined
): Record<string, DiffComment[]> | undefined {
const collected: Record<string, DiffComment[]> = {}
let kept = false
for (const workspace of workspaces ?? []) {
const comments = workspace.diffComments
if (Array.isArray(comments) && comments.length > 0) {
collected[workspace.id] = comments
kept = true
}
}
return kept ? collected : undefined
}
+2 -2
View File
@@ -185,7 +185,7 @@ import {
logStartupMilestone
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { removeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import {
createServeDesktopActivationGate,
@@ -2172,7 +2172,7 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
removeLegacyTerminalShimDir(app.getPath('userData'))
neutralizeLegacyTerminalShimDir(app.getPath('userData'))
const windowsShellPathHydration = createWindowsShellPathHydration()
configureWindowsHostGitEnvironmentReadiness(
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
@@ -45,6 +45,7 @@ const SNAPSHOT = {
worktreeName: 'Dashboard',
hostKind: 'ssh',
executionHostId: 'ssh:build-box',
hostLabel: 'Build box',
workspaceKind: 'worktree',
workspaceStatusId: 'in-review',
workspaceStatusLabel: 'In review',
@@ -68,6 +69,7 @@ const SNAPSHOT = {
parentWorktreeId: 'parent-worktree-1',
hostKind: 'ssh',
executionHostId: 'ssh:build-box',
hostLabel: 'Build box',
workspaceKind: 'worktree',
workspaceStatusId: 'in-review',
workspaceStatusLabel: 'In review',
@@ -143,6 +145,12 @@ describe('dashboard payload validation', () => {
cards: [{ ...SNAPSHOT.cards[0], executionHostId: `ssh:${'x'.repeat(4_097)}` }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], hostLabel: 'x'.repeat(1_025) }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
@@ -236,6 +244,12 @@ describe('dashboard payload validation', () => {
workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
workspaces: [{ ...SNAPSHOT.workspaces[0], hostLabel: 'x'.repeat(1_025) }]
})
).toBe(false)
})
it('validates bounded launch choices and spawn requests', () => {
@@ -272,6 +272,7 @@ function isDashboardCard(value: unknown): boolean {
(card.executionHostId === undefined ||
(isBoundedString(card.executionHostId, MAX_ID_LENGTH) &&
normalizeExecutionHostId(card.executionHostId) !== null)) &&
isOptionalBoundedString(card.hostLabel, MAX_LABEL_LENGTH) &&
(card.workspaceKind === undefined ||
(typeof card.workspaceKind === 'string' &&
DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) &&
@@ -50,6 +50,7 @@ export function isDashboardWorkspace(value: unknown): value is DashboardWorkspac
HOST_KINDS.has(workspace.hostKind) &&
isString(workspace.executionHostId, MAX_ID_LENGTH) &&
normalizeExecutionHostId(workspace.executionHostId) !== null &&
isOptionalString(workspace.hostLabel, DASHBOARD_MAX_LABEL_LENGTH) &&
typeof workspace.workspaceKind === 'string' &&
WORKSPACE_KINDS.has(workspace.workspaceKind) &&
isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) &&
+53 -2
View File
@@ -248,6 +248,7 @@ import {
type PrepareCodexSessionResume
} from './pty'
import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path'
import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../pty/legacy-terminal-shim-dir'
import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader'
import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell'
import {
@@ -4399,6 +4400,16 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('asks surviving pre-upgrade daemons to delete legacy attribution env', async () => {
const spawnOptions = await daemonSpawnAndGetOptions({})
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
)
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
})
it('deletes stale Claude scoped settings env from runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
@@ -4436,6 +4447,38 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('asks surviving pre-upgrade daemons to delete legacy attribution env for runtime PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} })
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining([...LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS])
)
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GIT')
expect(spawnOptions.envToDelete).not.toContain('ORCA_REAL_GH')
})
it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => {
// Why: the runtime controller is the `orca` CLI / automation spawn path and
// assembles envToDelete separately from the renderer's pty:spawn handler;
@@ -6253,7 +6296,7 @@ describe('registerPtyHandlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => {
it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn()
@@ -6296,22 +6339,30 @@ describe('registerPtyHandlers', () => {
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
kill: (ptyId: string) => boolean
retireRejectedPty: (ptyId: string) => void
}
try {
expect(controller.kill('remote-pty')).toBe(true)
await new Promise((resolve) => setImmediate(resolve))
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
controller.retireRejectedPty('remote-pty')
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
}
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
})
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
+53 -6
View File
@@ -113,6 +113,7 @@ import {
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
import {
isLegacyTerminalShimPathEntry,
LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS,
stripLegacyTerminalShimEnv
} from '../pty/legacy-terminal-shim-dir'
import { registerPty, unregisterPty } from '../memory/pty-registry'
@@ -3562,6 +3563,23 @@ export function registerPtyHandlers(
syntheticKillExitPtyIds.set(id, cleanupTimer)
}
// Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late
// synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the
// surface the retirement just removed. Timed like the duplicate window so a reused id is free.
const retiredRejectedPtyIds = new Map<string, NodeJS.Timeout>()
function rememberRetiredRejectedPty(id: string): void {
const existing = retiredRejectedPtyIds.get(id)
if (existing) {
clearTimeout(existing)
}
const cleanupTimer = setTimeout(() => {
retiredRejectedPtyIds.delete(id)
}, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
cleanupTimer.unref?.()
retiredRejectedPtyIds.set(id, cleanupTimer)
}
function consumeSyntheticKillExit(id: string): boolean {
const cleanupTimer = syntheticKillExitPtyIds.get(id)
if (!cleanupTimer) {
@@ -4715,6 +4733,8 @@ export function registerPtyHandlers(
spawnOptions.envToDelete = mergePtyEnvDeletions(
authEnvToDelete,
args.envToDelete ?? [],
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [],
// Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn.
getInheritedClaudeSessionStampEnvKeysToDelete(env)
@@ -5469,19 +5489,23 @@ export function registerPtyHandlers(
// Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit.
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
.then((providerExitObserved) => {
const retired = retiredRejectedPtyIds.has(ptyId)
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
if (!providerExitObserved && !retired) {
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
})
.catch((err) => {
const retired = retiredRejectedPtyIds.has(ptyId)
if (isPtyAlreadyGoneError(err)) {
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
if (!retired) {
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
return
}
console.warn(
@@ -5489,7 +5513,9 @@ export function registerPtyHandlers(
)
// Why: close runtime tails without clearing provider ownership, so
// a retry can still target a PTY that survived the failed shutdown.
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
if (!retired) {
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
return true
}
@@ -5500,12 +5526,31 @@ export function registerPtyHandlers(
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
if (!retiredRejectedPtyIds.has(ptyId)) {
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
}
})
return true
}
return killWithCurrentProvider()
},
retireRejectedPty: (ptyId) => {
rememberRetiredRejectedPty(ptyId)
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
// renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an
// SSH pane retires for good instead of staying preserved by the stop's negative exit.
if (!ptyOwnership.has(ptyId)) {
runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId))
return
}
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
connectionId ??= parsedSshId?.connectionId
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, 0, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: 0 })
},
markReversibleStops: (ptyIds) => {
for (const ptyId of ptyIds) {
reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1)
@@ -6354,6 +6399,8 @@ export function registerPtyHandlers(
envToDelete,
args.envToDelete ?? [],
agentTeamsEnvToDelete ?? [],
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [],
getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [],

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