Merge remote-tracking branch 'origin/main' into brennanb2025/agent-group-routing-identity

This commit is contained in:
Brennan Benson
2026-08-27 15:03:04 -07:00
2413 changed files with 231652 additions and 30762 deletions
+9 -11
View File
@@ -1,16 +1,15 @@
name: Adhoc macOS Dev Build
name: Adhoc macOS + Windows Dev Build
# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can
# actually run an experimental feature for a few days, instead of reasoning about
# it from a diff. Hourly covers main; this covers everything that is not main yet.
# Why: lets anyone cut installable macOS and Windows builds of a trusted repo ref
# so the team can run an experimental feature instead of reasoning from a diff.
# Hourly covers current main; this covers experiments and on-demand validation.
#
# Deliberately narrow scope, same trade as hourly:
# - macOS only. Other platforms keep using RC/stable.
# Deliberately narrow scope:
# - macOS and Windows desktop installers. Linux keeps using RC/stable.
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates.
# - Signed AND notarized, exactly like a release. macOS anchors a notarized
# app's TCC grants on identifier + team rather than on its cdhash, so those
# grants survive an update; an unnotarized build reads as a new client and
# silently loses file access under Documents/Desktop/Downloads.
# - macOS is signed and notarized so TCC grants survive updates.
# - Windows is unsigned; the published release notes explain the one-time
# SmartScreen/manual-install requirement.
#
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
# orca-hourly. Separate from orca because the main repo's releases atom feed
@@ -311,7 +310,6 @@ jobs:
including back to Stable, works in-app from there."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish adhoc macOS artifacts
uses: nick-fields/retry@v4
with:
@@ -230,10 +230,23 @@ jobs:
shell: bash
run: node config/scripts/verify-dev-channel-packaging.mjs --channel="$CHANNEL" --platform=win32
# Why here and not in build:relay: only a Windows runner can compile it, and
# arm64 cross-compiles from this same x64 agent. Runs before the 20-minute
# build so a runner image missing the MSVC ARM64 cross toolset fails in
# seconds with MSB8020 naming the component, rather than deep into packaging.
- name: Build Windows process-table addon for the relay
shell: bash
run: |
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
- name: Build app
shell: bash
run: pnpm build:release
env:
# Fail the build rather than ship a relay that silently falls back to
# the PowerShell scan on every Windows SSH host.
ORCA_REQUIRE_RELAY_NATIVE_ADDONS: 'x64,arm64'
# Why unset ORCA_BUILD_IDENTITY: telemetry's transport gate accepts only
# 'stable' or 'rc', so leaving it unset keeps dev builds silent — which
# is correct for unvetted artifacts. Same as the mac dev channels.
+23 -2
View File
@@ -425,6 +425,7 @@ jobs:
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
src/shared/pty-reply-echo-shapes.node-pty.test.ts \
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
@@ -470,6 +471,7 @@ jobs:
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
@@ -539,8 +541,11 @@ jobs:
# A path filter that matches nothing exits 1 ("No test files found"), so this
# lane cannot report success while running zero tests.
- name: Old/new client and server terminal journey
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
- name: Old/new client and server compatibility journeys
run: >-
pnpm exec vitest run --config config/vitest.config.ts
tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
managed_hook_node18:
name: managed hooks on Node 18
@@ -593,6 +598,15 @@ jobs:
with:
native-runtime: electron
- name: Test Linux Electron lifecycle boundary
run: >-
xvfb-run --auto-servernum pnpm exec vitest run --config config/vitest.config.ts
src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts
src/main/browser/browser-route-tcp-egress.electron.test.ts
src/main/browser/browser-route-webrtc-egress.electron.test.ts
src/main/browser/browser-route-h3-egress.electron.test.ts
src/main/browser/browser-route-dns-prefetch.electron.test.ts
- name: Build package inputs
run: |
status=0
@@ -681,9 +695,15 @@ jobs:
run: >-
pnpm exec vitest run --config config/vitest.config.ts
config/scripts/rebuild-native-deps.test.mjs
src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts
src/main/browser/browser-route-tcp-egress.electron.test.ts
src/main/browser/browser-route-webrtc-egress.electron.test.ts
src/main/browser/browser-route-h3-egress.electron.test.ts
src/main/browser/browser-route-dns-prefetch.electron.test.ts
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
src/shared/child-process/windows-command-line.win32.test.ts
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
src/main/windows/windows-pty-job.win32.test.ts
src/main/windows/windows-host-job.win32.test.ts
src/main/wsl/wsl-runner.test.ts
@@ -693,6 +713,7 @@ jobs:
src/main/wsl/wsl-w1-w3-contract.test.ts
src/shared/source-scan/source-tree-scan.test.ts
src/main/cli/wsl-cli-powershell-boundary.test.ts
src/main/cursor/hook-service.test.ts
src/main/orca-profiles/profile-index-store.test.ts
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
+5 -2
View File
@@ -17,6 +17,9 @@ src/**/*.d.ts
# Dependencies
node_modules/
# Why: the trailing-slash form matches directories only, so a node_modules SYMLINK (how agent
# worktrees share an install) slipped past a bulk `git add` and got committed — twice.
node_modules
# Build output
dist/
@@ -95,13 +98,13 @@ docs/**
!docs/readme/**
!docs/STYLEGUIDE.md
!docs/agent-skill-sharing-implementation-checklist.md
!docs/agent-skill-sharing-installation-plan.md
!docs/ai-vault-process-isolation-plan.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/macos-press-and-hold.md
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-process-enumeration.md
!docs/reference/wsl-runner-verification.md
@@ -24,8 +24,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
'agent-hooks/managed-agent-hook-controls'
] as const
// Entries executed as worker threads of the main process. Electron's module is
-1
View File
@@ -14,7 +14,6 @@
"src/main/ports/port-scan-command-worker-entry.ts",
"src/main/ipc/parcel-watcher-process-entry.ts",
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
"src/main/codex/codex-app-server-grant-entry.ts",
"src/main/agent-hooks/managed-agent-hook-controls.ts",
"src/main/claude-accounts/keychain.ts",
"src/renderer/src/main.tsx",
@@ -1,4 +1,18 @@
[
{
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
"kind": "object-property:title",
"text": "Nightly #1",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
"kind": "object-property:label",
"text": "Hermes",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
"kind": "jsx-attribute:label",
@@ -2,13 +2,22 @@ diff --git a/binding.gyp b/binding.gyp
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -3,7 +3,7 @@
{
"target_name": "windows_process_tree",
"dependencies": [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
],
"conditions": [
['OS=="win"', {
@@ -16,9 +16,6 @@
],
"include_dirs": [],
"libraries": [ 'psapi.lib' ],
- "msvs_configuration_attributes": {
- "SpectreMitigation": "Spectre"
- },
- "msvs_configuration_attributes": {
- "SpectreMitigation": "Spectre"
- },
"msvs_settings": {
"VCCLCompilerTool": {
"AdditionalOptions": [
@@ -20,7 +29,7 @@ index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5
process_info.push_back(std::move(pinfo));
process_count++;
}
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
+ } while (Process32Next(snapshot_handle, &process_entry));
}
File diff suppressed because one or more lines are too long
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's
* prebuilds matrix, so a deployment target needs no C/C++ toolchain.
*
* node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in
* this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix:
* `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that
* keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces
* #9902. So the matrix is compiled from patched sources here, and this script refuses to
* run if the patch is not in the tree it is about to compile.
*
* orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies
* only platform/arch/libc:
* linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl,
* darwin-x64, darwin-arm64
*
* CI runs this once per slot, each inside the container that owns that libc/arch, and
* merges the resulting `out/orcad/prebuilds` trees. `--slot=<name>` forces the label so
* the glibc/musl distinction is recorded from the container rather than detected.
*
* Usage:
* node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl]
* node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { spawnSync } from 'node:child_process'
const require = createRequire(import.meta.url)
const ROOT = join(import.meta.dirname, '..', '..')
const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds')
/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */
export const MATRIX_SLOTS = [
'linux-x64-glibc',
'linux-arm64-glibc',
'linux-x64-musl',
'linux-arm64-musl',
'darwin-x64',
'darwin-arm64'
]
/**
* Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the
* process is linked against glibc, and musl images have no `ldd` worth parsing.
*/
export function detectLibc(platform = process.platform, header = readReportHeader()) {
if (platform !== 'linux') {
return 'none'
}
return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
}
function readReportHeader() {
try {
return process.report?.getReport?.()?.header
} catch {
return undefined
}
}
export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) {
const forced = argv.find((arg) => arg.startsWith('--slot='))
if (forced) {
return forced.slice('--slot='.length)
}
const libc = detectLibc(platform)
return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}`
}
/**
* The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary
* that loads fine on the build host and dies on the target — the exact failure the matrix
* exists to prevent, now baked into a shipped artifact instead of a first-connect error.
*/
export function assertNodePtyPatchApplied(nodePtyDir) {
const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8')
const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8')
const missing = []
if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) {
missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag")
}
if (!ptySource.includes('.symver openpty,openpty@')) {
missing.push('src/unix/pty.cc is missing the .symver glibc pins')
}
if (missing.length > 0) {
throw new Error(
[
'[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.',
...missing.map((line) => ` - ${line}`),
'A prebuilt compiled without it will not load on Ubuntu 20.04 (see',
'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.'
].join('\n')
)
}
}
export function readManifest(prebuildsDir) {
try {
return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8'))
} catch {
return null
}
}
/**
* Why merge rather than overwrite: CI builds one slot per container and merges the trees.
* A manifest that records only the last slot would erase every other container's record,
* and `--require-slots` would then reject a complete matrix.
*/
export function mergeManifest(existing, next) {
const slots = new Set([...(existing?.slots ?? []), next.slot])
return {
module: 'node-pty',
version: next.version,
nodeAbi: next.nodeAbi,
slots: [...slots].sort()
}
}
function nodePtyDir() {
return dirname(require.resolve('node-pty/package.json'))
}
function compileNodePty(dir) {
const built = join(dir, 'build', 'Release', 'pty.node')
if (existsSync(built)) {
console.log(`[orcad-prebuilds] reusing existing build at ${built}`)
return built
}
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], {
cwd: dir,
stdio: 'inherit',
env: process.env,
windowsHide: true
})
if (result.status !== 0) {
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
}
if (!existsSync(built)) {
throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`)
}
return built
}
function requireSlots() {
const manifest = readManifest(PREBUILDS_DIR)
const have = new Set(manifest?.slots ?? [])
const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot))
if (missing.length > 0) {
console.error(
`[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` +
'Hosts on those slots fall back to a source build and need a C/C++ toolchain.'
)
process.exitCode = 1
return
}
console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`)
}
function build() {
const dir = nodePtyDir()
assertNodePtyPatchApplied(dir)
const slot = slotName()
const slotDir = join(PREBUILDS_DIR, slot)
mkdirSync(slotDir, { recursive: true })
const builtBinary = compileNodePty(dir)
copyFileSync(builtBinary, join(slotDir, 'pty.node'))
console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
// Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
// so a slot without it installs cleanly and then fails ENOENT the first time a user
// opens a terminal. Windows has no spawn-helper.
if (process.platform !== 'win32') {
const helperSource = join(dirname(builtBinary), 'spawn-helper')
if (!existsSync(helperSource)) {
throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
}
copyFileSync(helperSource, join(slotDir, 'spawn-helper'))
console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`)
}
// The static floor gate, applied to the artifact we are about to ship rather than only
// to the packaged desktop app. objdump is Linux-only, which is where the floor lives.
if (process.platform === 'linux') {
const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs')
verifyLinuxGlibcFloor(slotDir)
}
const manifest = mergeManifest(readManifest(PREBUILDS_DIR), {
slot,
version: require('node-pty/package.json').version,
nodeAbi: process.versions.modules
})
writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
console.log(
`[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}`
)
}
if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) {
if (process.argv.includes('--require-slots')) {
requireSlots()
} else {
build()
}
}
@@ -0,0 +1,121 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertNodePtyPatchApplied,
detectLibc,
MATRIX_SLOTS,
mergeManifest,
readManifest,
slotName
} from './build-orcad-prebuilds.mjs'
const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
const dirs = []
const stage = (bindingGyp, ptyCc) => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-'))
dirs.push(dir)
mkdirSync(join(dir, 'src', 'unix'), { recursive: true })
writeFileSync(join(dir, 'binding.gyp'), bindingGyp)
writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc)
return dir
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('assertNodePtyPatchApplied', () => {
it('accepts a tree with both halves of the glibc-floor fix', () => {
expect(() =>
assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC))
).not.toThrow()
})
it('refuses to build when the ldflags half is missing', () => {
// The .symver pins alone let gcc's --as-needed drop libutil/libpthread from
// DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again,
// this time baked into a shipped prebuilt.
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow(
/--no-as-needed,-l:libutil\.so\.1/
)
})
it('refuses to build when the .symver pins are missing', () => {
expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow(
/\.symver glibc pins/
)
})
it('names the patch and the doc so the fix is findable', () => {
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow(
/config\/patches\/node-pty@1\.1\.0\.patch/
)
})
})
describe('slot naming', () => {
it('covers every platform orcad ships to', () => {
expect([...MATRIX_SLOTS].sort()).toEqual([
'darwin-arm64',
'darwin-x64',
'linux-arm64-glibc',
'linux-arm64-musl',
'linux-x64-glibc',
'linux-x64-musl'
])
})
it('lets CI force the label so the container decides glibc vs musl', () => {
// Detection inside a container that happens to run a differently-linked Node would
// file the build under the wrong slot. The forced label must beat detection outright,
// so assert against one detection could never produce for this platform/arch.
expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe(
'linux-x64-glibc'
)
})
it('omits the libc dimension off Linux', () => {
expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64')
})
it('reads glibc from the report header and musl from its absence', () => {
expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc')
expect(detectLibc('linux', {})).toBe('musl')
expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none')
})
})
describe('mergeManifest', () => {
it('accumulates slots across the per-container CI runs that build them', () => {
// Overwriting would erase every other container's record, and the release gate would
// then reject a matrix that is actually complete.
const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' })
const second = mergeManifest(first, {
slot: 'linux-arm64-musl',
version: '1.1.0',
nodeAbi: '127'
})
expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc'])
expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' })
})
it('does not duplicate a slot rebuilt twice', () => {
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots)
.toEqual(['darwin-arm64'])
})
})
describe('readManifest', () => {
it('returns null instead of throwing when no matrix has been built', () => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-'))
dirs.push(dir)
expect(readManifest(dir)).toBeNull()
})
})
+123 -38
View File
@@ -9,10 +9,25 @@
*/
import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { createHash } from 'node:crypto'
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
import {
ORCAD_VERSION,
ORCAD_VERSION_FILENAME,
orcadArtifactFilenames
} from '../../src/shared/orcad-artifacts.ts'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
@@ -22,6 +37,11 @@ const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process,
// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every
// orcad restart would SIGKILL every running terminal.
const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
const OUT_FILE = join(OUT_DIR, 'orcad.js')
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
@@ -64,20 +84,31 @@ if (process.platform !== 'win32') {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
}
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: WATCHER_OUT_FILE,
external: EXTERNAL,
plugins: [externalNativeAddons],
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
* their runtime resolvers look for them. */
function buildForkedChild(entryPoint, outfile) {
return build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile,
external: EXTERNAL,
plugins: [externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
}
const childResults = await Promise.all([
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
])
const result = await build({
entryPoints: [ENTRY],
@@ -95,29 +126,36 @@ const result = await build({
logLevel: 'error'
})
const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
const output = Object.values(result.metafile.outputs).find(
(o) => o.entryPoint === 'src/main/orcad/main.ts'
)
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
const electronImporters = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
const specifier = imported.original ?? imported.path
if (specifier === 'electron' || specifier.startsWith('electron/')) {
electronImporters.add(file)
}
}
}
const sqliteImporters = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
const specifier = imported.original ?? imported.path
if (specifier === 'node:sqlite') {
sqliteImporters.add(file)
// Why both metafiles: the forked children ship in the same deployment and run under the
// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
// path whose whole point is that terminals survive.
function collectImporters(metafiles, matches) {
const importers = new Set()
for (const metafile of metafiles) {
for (const [file, info] of Object.entries(metafile.inputs)) {
for (const imported of info.imports ?? []) {
if (matches(imported.original ?? imported.path)) {
importers.add(file)
}
}
}
}
return importers
}
const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
const electronImporters = collectImporters(
metafiles,
(specifier) => specifier === 'electron' || specifier.startsWith('electron/')
)
const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite')
const graphErrors = []
if (electronImporters.size > 0) {
graphErrors.push(
@@ -147,23 +185,53 @@ if (graphErrors.length > 0) {
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
// because it is an esbuild artifact rather than a rollup input.
// Why an exit code and not a message match: these bundles are minified onto one line, so
// Node's uncaught-exception report echoes that whole line — which contains every string
// literal in the bundle. A crash therefore "matches" any expected message, and a textual
// assertion passes against a bundle that never loaded.
const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
encoding: 'utf8',
timeout: 60_000
})
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
if (
smoke.error ||
smoke.signal ||
!/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput)
) {
if (smoke.error || smoke.signal || smoke.status !== 0) {
console.error(
`[build-orcad] the bundle did not load under plain Node.\n` +
`Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` +
`Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
`signal=${smoke.signal ?? 'none'} ` +
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
// Why require + parseArgs and not a real daemon: requiring the bundle evaluates every
// top-level import, and calling its exported argv parser proves the entry's own code is
// there rather than a graph that merely resolved. Booting one would need a socket, a
// token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad.
// The verdict is carried by the exit code for the same minification reason as above.
const daemonSmoke = spawnSync(
process.execPath,
[
'-e',
`const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` +
`if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` +
`try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }`
],
{
encoding: 'utf8',
timeout: 60_000,
env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' }
}
)
const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
console.error(
`[build-orcad] the daemon child did not load under plain Node.\n` +
`Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
`signal=${daemonSmoke.signal ?? 'none'} ` +
`error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
const watcherFailure = await smokeLoadWatcherChild()
if (watcherFailure) {
console.error(
@@ -173,9 +241,26 @@ if (graphErrors.length > 0) {
}
}
// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
// this string, so two different builds carrying one version would share a directory — and an
// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
// bytes while reporting the new version.
if (process.exitCode !== 1) {
const hash = createHash('sha256')
for (const filename of orcadArtifactFilenames()) {
const artifactPath = join(OUT_DIR, filename)
if (!existsSync(artifactPath)) {
throw new Error(
`orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` +
'step, or drop it from src/shared/orcad-artifacts.ts.'
)
}
hash.update(readFileSync(artifactPath))
}
const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`
writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
console.log(
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
)
}
+73 -3
View File
@@ -23,6 +23,8 @@ import { join } from 'node:path'
import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
RELAY_WINDOWS_PROCESS_TREE_FILENAME,
relayOptionalArtifactFilenames,
isWindowsRelayPlatform,
relayArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
@@ -55,6 +57,38 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
// Written by build-windows-process-tree-relay-addon.mjs, which only runs on a
// Windows machine.
const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree')
// Which Windows arches must have the addon, as a comma-separated list ('all' for
// every arch). Per-arch rather than a flag because arm64 needs the MSVC ARM64
// cross toolset, an optional VS component: where it is absent that relay should
// fall back to the scan, not fail the release the x64 relay is riding on.
const REQUIRED_ADDON_ARCHES = (process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
function stageWindowsProcessTreeAddon(platform, outDir) {
if (!isWindowsRelayPlatform(platform)) {
return
}
const arch = platform.slice('win32-'.length)
const source = join(WINDOWS_PROCESS_TREE_BUILD_DIR, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME)
if (!existsSync(source)) {
if (REQUIRED_ADDON_ARCHES.includes(arch) || REQUIRED_ADDON_ARCHES.includes('all')) {
throw new Error(
`Relay ${platform} needs ${source}. Run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch} (Windows only).`
)
}
console.log(
`Relay ${platform}: no ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}; relay will use the PowerShell scan.`
)
return
}
copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME))
}
// Why: lets the packaging contract test build into a temp tree instead of
// clobbering a developer's out/relay or racing tests that read it.
@@ -92,6 +126,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
}
stageWindowsProcessTreeAddon(platform, outDir)
await build({
entryPoints: [WATCHER_ENTRY],
@@ -172,12 +207,25 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
}
hash.update(readFileSync(artifactPath))
}
// Why hashed only when present: a relay carrying the native addon answers
// differently from one that falls back to the scan, so the two must not share
// an immutable directory -- but a build without it is still valid.
for (const filename of relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))) {
const artifactPath = join(outDir, filename)
if (existsSync(artifactPath)) {
hash.update(readFileSync(artifactPath))
}
}
const contentHash = hash.digest('hex').slice(0, 12)
// Close the loop: an artifact emitted here but absent from the manifest would
// ship unhashed and unprobed — exactly how the WSL helper went missing.
const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME)
const undeclared = emitted.filter((name) => !expected.includes(name))
const declared = [
...expected,
...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))
]
const undeclared = emitted.filter((name) => !declared.includes(name))
if (undeclared.length > 0) {
throw new Error(
`Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` +
@@ -194,11 +242,12 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
// so a single platform-independent bundle suffices; it ships inside the
// Windows app via the same out/relay extraResources mapping.
{
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const wslHookEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const wslBrowserNetworkEntry = join(ROOT, 'src', 'relay', 'wsl-browser-network-relay.ts')
const outDir = join(OUT_ROOT, 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslEntry],
entryPoints: [wslHookEntry],
bundle: true,
platform: 'node',
target: 'node18',
@@ -214,6 +263,27 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
await build({
entryPoints: [wslBrowserNetworkEntry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-browser-network-relay.js'),
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
const browserNetworkContent = readFileSync(join(outDir, 'wsl-browser-network-relay.js'))
const browserNetworkHash = createHash('sha256')
.update(browserNetworkContent)
.digest('hex')
.slice(0, 12)
writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`)
console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`)
}
console.log('Relay build complete.')
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Compile `@vscode/windows-process-tree` for a relay host.
*
* The relay is deployed to machines with no compiler, and this addon cannot be
* npm-installed there: it carries a binding.gyp, so npm rebuilds from source and
* the build wants Spectre-mitigated libraries even where MSVC is present. The
* binary inside the published tarball loads, but predates our patch and still
* caps enumeration at 1024 processes -- a busy host then gets a truncated table
* missing its own pid, which reads as "unavailable" only under load.
*
* So we compile it here, from the patched source pnpm already materialized, and
* ship the result as a relay artifact. Windows arm64 cross-compiles from an x64
* runner, so both arches come off one Windows job.
*
* Node headers, not Electron: the relay runs under the host's own `node`. The
* addon is N-API, so one build serves every Node the remote might have.
*
* node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
*/
import { execFileSync } from 'node:child_process'
import {
closeSync,
copyFileSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
readSync
} from 'node:fs'
import { join, resolve } from 'node:path'
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
const ROOT = resolve(import.meta.dirname, '..', '..')
const PACKAGE_DIR = join(ROOT, 'node_modules', '@vscode', 'windows-process-tree')
const SUPPORTED_ARCHES = ['x64', 'arm64']
/** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 }
function parseArgs(argv) {
const arch = argv.find((a) => a.startsWith('--arch='))?.slice('--arch='.length) ?? process.arch
const outDir = argv.find((a) => a.startsWith('--out='))?.slice('--out='.length)
if (!SUPPORTED_ARCHES.includes(arch)) {
throw new Error(`--arch must be one of ${SUPPORTED_ARCHES.join(', ')}; got ${arch}`)
}
return {
arch,
outDir: outDir ? resolve(outDir) : join(ROOT, '.build', 'windows-process-tree', arch)
}
}
/**
* Refuse to build unpatched source.
*
* Each hunk fails differently: Spectre dies outright, the 1024-process cap
* succeeds and lies, and `.targets` is cwd-relative so pnpm's nested layout
* makes node-gyp miss node_addon_api.gyp on Windows. Checking the source
* rather than trusting the install is what stops a silently unpatched tree
* from being shipped as if it were patched.
*/
function assertPatchApplied() {
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
if (bindingGyp.includes('SpectreMitigation')) {
throw new Error(
'binding.gyp still requests SpectreMitigation. pnpm did not apply ' +
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
throw new Error(
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
if (processCc.includes('process_count < 1024')) {
throw new Error(
'src/process.cc still caps enumeration at 1024 processes. pnpm did not apply ' +
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
}
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
function readPeMachine(binaryPath) {
const fd = openSync(binaryPath, 'r')
try {
const header = Buffer.alloc(4)
readSync(fd, header, 0, 4, 0x3c)
const peOffset = header.readUInt32LE(0)
const machine = Buffer.alloc(2)
readSync(fd, machine, 0, 2, peOffset + 4)
return machine.readUInt16LE(0)
} finally {
closeSync(fd)
}
}
function main() {
const { arch, outDir } = parseArgs(process.argv.slice(2))
if (process.platform !== 'win32') {
throw new Error(
`This addon only builds on Windows; running on ${process.platform}. ` +
'Relay builds elsewhere simply omit it and fall back to the CIM scan.'
)
}
if (!existsSync(PACKAGE_DIR)) {
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
}
assertPatchApplied()
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
execFileSync(
process.execPath,
[join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), 'rebuild', `--arch=${arch}`],
{ cwd: PACKAGE_DIR, stdio: 'inherit' }
)
const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node')
if (!existsSync(built)) {
throw new Error(`node-gyp reported success but ${built} is missing.`)
}
const machine = readPeMachine(built)
if (machine !== PE_MACHINE[arch]) {
throw new Error(
`Built binary is machine 0x${machine.toString(16)}, expected 0x${PE_MACHINE[arch].toString(16)} for ${arch}. ` +
'node-gyp ignored --arch; a relay would get a binary its host cannot load.'
)
}
mkdirSync(outDir, { recursive: true })
const staged = join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME)
copyFileSync(built, staged)
console.log(`[windows-process-tree] ${arch} -> ${staged}`)
}
try {
main()
} catch (error) {
console.error(`[windows-process-tree] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
}
@@ -0,0 +1,40 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
describe('client-hosted browser package coverage', () => {
it('bundles the WSL browser-network relay with its version stamp', () => {
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
expect(relayBuild).toContain("outfile: join(outDir, 'wsl-browser-network-relay.js')")
expect(relayBuild).toContain("join(outDir, '.browser-network-version')")
})
it('runs client-hosted Electron lifecycle coverage on native package hosts', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const linuxStep = parsedWorkflow.jobs.package.steps.find(
(step) => step.name === 'Test Linux Electron lifecycle boundary'
)
const windowsStep = parsedWorkflow.jobs.package_windows.steps.find(
(step) => step.name === 'Test Windows-specific boundaries'
)
const required = [
'browser-client-page-renderer-lifecycle',
'browser-route-webrtc-egress',
'browser-route-tcp-egress',
'browser-route-h3-egress',
'browser-route-dns-prefetch'
].map((name) => `src/main/browser/${name}.electron.test.ts`)
expect(linuxStep.run).toContain('xvfb-run --auto-servernum')
for (const file of required) {
expect(linuxStep.run).toContain(file)
expect(windowsStep.run).toContain(file)
}
})
})
+5 -1
View File
@@ -238,7 +238,11 @@ async function main() {
throw new Error('daemon readiness did not publish the expected PID ownership record')
}
log('PID ownership record matches the ready daemon')
if (!existsSync(socketPath)) {
const endpointPublished =
process.platform === 'win32'
? (await probeEndpoint(socketPath)) === 'connected'
: existsSync(socketPath)
if (!endpointPublished) {
throw new Error('daemon did not publish its endpoint at the canonical socket path')
}
log('endpoint published at the canonical socket path')
@@ -102,6 +102,10 @@ describe('Electron Vite output contract', () => {
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
})
it('bundles validation dependencies used by the sandboxed preload', () => {
expect(electronViteConfig.preload?.build?.externalizeDeps?.exclude).toContain('zod')
})
it('exits when a static import fails before source error guards load', () => {
const processMock = new EventEmitter() as EventEmitter & {
exit: (code: number) => void
+7 -1
View File
@@ -37,7 +37,13 @@ const RELOCALIZED_GENERIC_TERMS = [
export const JA_PHRASE_FIXES = [
...RELOCALIZED_GENERIC_TERMS,
{ pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
{
pattern: /エージェント/g,
replacement: 'Agent',
whenEnIncludes: 'agent',
// Skills filters and metadata are Japanese UI labels, not agent product prose.
skipKeyPrefixes: ['auto.components.skills.']
},
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
@@ -79,6 +79,16 @@ describe('locale-translation-policy ja relocalization', () => {
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
})
it('preserves translated Japanese Skills labels', () => {
expect(
ja(
'Filter by agent',
'エージェントで絞り込み',
'auto.components.skills.SkillsPage.filterProvider'
)
).toBe('エージェントで絞り込み')
})
it('leaves ranges and token samples out of the ellipsis rule', () => {
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
})
+7 -4
View File
@@ -351,12 +351,15 @@ function phraseFixMatchesEnglish(enValue, fix) {
return enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase())
}
function applyPhraseFixes(enValue, localeValue, locale) {
function applyPhraseFixes(enValue, localeValue, locale, key = '') {
let result = localeValue
for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) {
if (!phraseFixMatchesEnglish(enValue, fix)) {
continue
}
if (fix.skipKeyPrefixes?.some((prefix) => key.startsWith(prefix))) {
continue
}
result = result.replace(fix.pattern, fix.replacement)
}
return result
@@ -367,7 +370,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
if (keyOverride) {
// Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate.
let result = applyBrandMistranslationFixes(enValue, keyOverride, locale, key)
result = applyPhraseFixes(enValue, result, locale)
result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
@@ -377,7 +380,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue]
if (valueOverride) {
let result = applyBrandMistranslationFixes(enValue, valueOverride, locale, key)
result = applyPhraseFixes(enValue, result, locale)
result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
@@ -398,7 +401,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
}
result = applyBrandMistranslationFixes(enValue, result, locale, key)
result = applyPhraseFixes(enValue, result, locale)
result = applyPhraseFixes(enValue, result, locale, key)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
@@ -27,6 +27,47 @@ const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
/** The route that sends a change to the two-Electron restart-survival spec. */
const restartSurvivalRoute = PR_E2E_SOURCE_ROUTES.find(
(route) => route.id === 'client-hosted-browser.restart-survival'
)
describe('restart-survival E2E routing', () => {
// Every file below carries behavior the restart spec is the only test that exercises end to end.
it.each([
'src/main/runtime/orca-runtime.ts',
'src/main/runtime/orca-runtime-browser.ts',
'src/main/runtime/client-hosted-page-reconciliation-window.ts',
'src/main/runtime/runtime-browser-client-page-adoption.ts',
'src/main/runtime/runtime-browser-client-page-recovery.ts',
'src/main/runtime/browser-host-client-page-adoption.ts',
'src/main/runtime/browser-host-page-reconciliation-orchestration.ts',
'src/main/runtime/rpc/methods/browser-client-host.ts',
'src/main/browser/browser-client-host-authority-replacement-wait.ts',
'src/main/browser/paired-runtime-browser-client-host-composition.ts',
'src/renderer/src/runtime/web-session-tabs-sync.ts',
'src/renderer/src/runtime/host-session-snapshot-authority.ts',
'src/renderer/src/runtime/restored-client-hosted-browser-host-attach.ts',
'src/renderer/src/store/slices/runtime-status.ts',
'src/shared/runtime-types.ts',
'src/shared/browser-client-host-protocol.ts'
])('routes %s', (path) => {
expect(restartSurvivalRoute.matches(path)).toBe(true)
})
// The pattern is deliberately not "anything under src": routing every PR at a two-Electron spec
// is the cost the filter exists to avoid.
it.each([
'src/main/git/git-status.ts',
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
'src/main/terminal/pty-manager.ts',
// The status/types entries name whole files, not a suffix any longer name may end with.
'src/shared/computer-use-runtime-types.ts'
])('does not route %s', (path) => {
expect(restartSurvivalRoute.matches(path)).toBe(false)
})
})
describe('PR E2E gate contract', () => {
it('keeps E2E advisory while the suite is red on main', () => {
// Why: pin the deliberate choice so it reads as intentional rather than as
+26
View File
@@ -89,6 +89,32 @@ export const PR_E2E_SOURCE_ROUTES = [
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
file
)
},
{
// Why: the host's row for a client-rendered page only exists across two real Electron
// apps, so this spec is the only gate on it. The high-churn seams it also rides
// (ipc/runtime, useIpcEvents, preload) are left out deliberately: routing on those runs a
// two-app e2e on most PRs, and their client-hosted share is already covered by the
// main-process integration test.
id: 'client-hosted-browser.host-strip',
specs: ['tests/e2e/paired-client-hosted-browser-host-strip.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/.*(?:[Cc]lient-?[Hh]osted-?[Bb]rowser|BrowserPaneOverlayLayer)/.test(file)
},
{
// Why a second, wider pattern: restart survival breaks from seams that never say
// "client-hosted" - page adoption, the host lease/reconciliation plan, the session-tab
// snapshot the client culls rows against. orca-runtime.ts is included despite its churn: it
// publishes the snapshot flag the client holds its rows on, and no narrower path names that
// seam.
id: 'client-hosted-browser.restart-survival',
specs: ['tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test(
file
)
}
]
@@ -190,8 +190,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
app: MemorySnapshot['app']
host: MemorySnapshot['host']
processMemoryMetric: MemorySnapshot['processMemoryMetric']
processCommitMetric: MemorySnapshot['processCommitMetric']
totalCpu: number
totalMemory: number
totalPrivateMemory: MemorySnapshot['totalPrivateMemory']
worktreeCount: number
sessionCount: number
worktreeMemory: number
@@ -208,8 +210,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
app: snapshot.app,
host: snapshot.host,
processMemoryMetric: snapshot.processMemoryMetric,
processCommitMetric: snapshot.processCommitMetric,
totalCpu: snapshot.totalCpu,
totalMemory: snapshot.totalMemory,
totalPrivateMemory: snapshot.totalPrivateMemory,
worktreeCount: snapshot.worktrees.length,
sessionCount: snapshot.worktrees.reduce(
(total, worktree) => total + worktree.sessions.length,
@@ -0,0 +1,42 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_LOCAL_SSH_BROWSER: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/local-ssh-browser-routing.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -0,0 +1,42 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -0,0 +1,38 @@
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const PATCH = readFileSync(
join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch'),
'utf8'
)
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
describe('windows-process-tree node-addon-api gyp path', () => {
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
expect(PATCH).toContain(
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
)
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
expect(bindingGyp).toContain(ABSOLUTE_GYP)
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
expect(
readFileSync(
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
'utf8'
)
).toContain(ABSOLUTE_GYP)
})
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
cwd: PACKAGE_DIR,
encoding: 'utf8'
}).trim()
expect(isAbsolute(resolved)).toBe(true)
expect(existsSync(resolved)).toBe(true)
})
})
+12 -2
View File
@@ -5,6 +5,7 @@
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-post-command.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
@@ -30,8 +31,6 @@
"../src/main/codex/codex-app-server-capability-cache.ts",
"../src/main/codex/codex-app-server-capability-signal.ts",
"../src/main/codex/codex-app-server-client.ts",
"../src/main/codex/codex-app-server-grant-bridge.ts",
"../src/main/codex/codex-app-server-grant-envelope.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
@@ -40,6 +39,7 @@
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
"../src/main/codex/codex-managed-trust-grant-plan.ts",
"../src/main/codex/codex-path-observation.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
@@ -48,6 +48,7 @@
"../src/main/codex/codex-state-db.ts",
"../src/main/codex/codex-trust-identity.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-config-mutation-queue.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
@@ -82,9 +83,18 @@
"../src/main/copilot/copilot-managed-script.ts",
"../src/main/copilot/copilot-remote-hook-install.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-events.ts",
"../src/main/cursor/hook-script.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
"../src/main/grok/grok-hook-config.ts",
"../src/main/grok/grok-hook-config-cleanup.ts",
"../src/main/grok/grok-hook-config-file.ts",
"../src/main/grok/grok-hook-owners.ts",
"../src/main/grok/grok-hook-remote-install.ts",
"../src/main/grok/grok-hook-script.ts",
"../src/main/grok/grok-hook-symlink-cleanup-marker.ts",
"../src/main/grok/hook-service.ts",
"../src/main/grok/windows-grok-hook-script.ts",
"../src/main/devin/hook-settings.ts",
@@ -314,8 +314,6 @@ Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL se
identical to macOS. The user-driven signed-in desktop and real-host production journey, supported
Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep.
Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md).
This checklist turns the architecture plan into ordered implementation and release work. A checked
item means evidence exists in code, tests, reviewed infrastructure, or release documentation; it
does not mean the surrounding phase is complete.
File diff suppressed because it is too large Load Diff
-596
View File
@@ -1,596 +0,0 @@
# AI Vault process isolation architecture and implementation plan
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
Last updated: 2026-08-09.
## Decision summary
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
The target has three rules:
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
## Goals
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
- The service can be restarted without restarting the app, runtime, relay, or terminals.
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
- Mixed-version remote peers continue to work.
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
## Non-goals
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
- Rewriting the scanner or changing session discovery semantics during the process migration.
- Moving xterm into another renderer.
- Adding a process per window, worktree, repository, or SSH request.
- Removing the old-relay compatibility fallback in the same change.
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
## Current architecture and coupling
| Path | Current execution | Remaining coupling |
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
## Feature-parity contract
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
| Capability | Required behavior after migration | Owner |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
## Target topology
```text
Desktop / runtime host
Integrated renderer
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
│
└─ host merge + compatibility routing
SSH execution host
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
└────────────────── PTY handler/event loop
```
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
## Process ownership
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
## Service responsibilities
The service owns:
- Source discovery and filesystem metadata reads.
- Transcript streaming and parsing for all supported agents.
- OpenCode SQLite reads and its nested worker lifecycle.
- In-memory and persisted session parse caches.
- The bounded title index and transcript title resolution.
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
- Scan parse concurrency and cooperative cancellation.
- Result construction, deduplication, sorting, and internal paging.
- Per-request metrics that contain counts and timings, not session content.
The router owns:
- Electron IPC, runtime RPC, or relay method registration.
- Authentication and connection ownership already enforced by that transport.
- Public argument validation and execution-host selection.
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
- Multi-host result merging.
- Request coalescing and mapping caller cancellation to the service request.
- Validation of service responses before publishing them across a public boundary.
- Compatibility fallback selection.
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
## Internal process protocol
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
Parent to service:
```ts
type ServiceRequest =
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
| { type: 'invalidate'; paths: string[]; generation: number }
| { type: 'cancel'; id: number }
| { type: 'shutdown' }
```
Service to parent:
```ts
type ServiceResponse =
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
| { type: 'invalidated'; generation: number }
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
| { type: 'fatal'; code: ServiceErrorCode; message: string }
```
Protocol rules:
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
- Continue returning the last good renderer result when a refresh fails.
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
## Lifecycle and supervision
### Startup
- Start lazily on the first scan or title request.
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
### Steady state
- Reuse the process and caches across calls.
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
### Faults and restart
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
- Retry queued work in a new process only when it is safe and has not been cancelled.
- Back off at 250 ms, 1 second, then 5 seconds.
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
## Resource and quality-of-service policy
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
- Queue: 16 calls, coalescing, background replacement, explicit priority.
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
## Renderer publication
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
Add `ai-vault-session-publication-gate.ts` with this behavior:
1. Cache the validated result immediately so another caller can reuse it.
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
3. Otherwise retain only the newest pending result and wait for quiet.
4. Publish after 1 second even if input continues, using a transition and one bounded page.
5. Cancel a pending publication when the scope, host, or component request token changes.
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
## Desktop and runtime implementation
Create these concrete modules:
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
- `src/main/ai-vault/cached-session-list.ts`
- `src/main/ai-vault/session-title-resolver.ts`
- `src/main/ipc/ai-vault-subagent-list.ts`
- `src/main/ai-vault/session-first-user-prompt-read.ts`
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
- `src/main/ipc/ai-vault.ts`
- `src/main/runtime/rpc/methods/ai-vault.ts`
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
## SSH relay sidecar implementation
Create:
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
If the sidecar cannot start or crashes:
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
- Do not scan inline in the relay.
- Keep all PTYs, reconnect state, and other relay methods available.
- Allow the next manual refresh to cross the restart circuit breaker once.
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
## Remote compatibility
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
- New desktop with current relay: calls the same methods and receives the same result shape.
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
## Folder workspaces, WSL, and paths
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
- Continue using `path.join` and the existing host-platform path adapters.
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
## Security boundary
The service is a performance and reliability boundary, not a privilege boundary.
- It has the same OS user and must access the same agent stores.
- Do not expose its IPC channel to renderer code or the network.
- The parent passes validated options; the service validates again before filesystem access.
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
- Preserve symlink and allowed-root checks for title reads.
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
## Packaging
Desktop packaging changes:
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
- Reuse the existing unpacked entry-path pattern used by other forked processes.
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
Relay packaging changes:
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
- Include it in `.version` content hashing and deploy completeness assertions.
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
## Observability
Add spans and counters for:
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
- Parent/child serialization duration and serialized bytes.
- Child RSS and heap-used snapshots at request completion.
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
- Relay sidecar failures separately from PTY and relay-loop health.
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
## Verification matrix
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
| Layer | Required coverage |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
## Failure behavior
| Failure | User-visible Vault behavior | Terminal behavior |
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
## Benchmark methodology
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
Each iteration:
1. Keeps the Vault panel mounted in both arms.
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
3. Alternates arm order to reduce warm-up bias.
4. Runs paced raw-mode terminal echo typing as the control.
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
## Current measurements
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
Pre-implementation parity baseline:
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
### Representative pass
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
| Metric | Control | Refresh active |
| ------------------------------ | -------------------: | ------------------: |
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
| Worst timer drift | 14.2 ms | 24.5 ms |
| Worst frame gap | 9.4 ms | 24.9 ms |
| Renderer long tasks over 50 ms | 0 | 0 |
| Missing echoes | 0 / 300 | 0 / 300 |
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
### Stress pass
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
| Metric | Control | Refresh active |
| ------------------------------ | ------------------: | -------------------: |
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
| Worst timer drift | 9.3 ms | 43.6 ms |
| Worst frame gap | 9.4 ms | 50.0 ms |
| Renderer long tasks over 50 ms | 0 | 0 |
| Missing echoes | 0 / 240 | 0 / 240 |
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
### Measurement limits
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
## Post-implementation A/B
Run the same build with the rollout switch off and on, alternating launch order:
```bash
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
```
Run at least five representative and five stress passes on:
- macOS Apple Silicon.
- Windows x64 with a folder workspace and a WSL-backed source present.
- Ubuntu 20.04-compatible x64 packaging/runtime.
- A 4-core or smaller machine or constrained CI runner.
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
## Implemented result
The implementation now matches the target topology:
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
Local validation on 2026-08-09:
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
| Metric | Worker control | Worker refresh | Process control | Process refresh |
| --------------------- | -------------: | -------------: | --------------: | --------------: |
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
## Acceptance criteria
Correctness:
- Zero missing terminal echoes.
- The latest seeded Vault session is visibly published in every treatment arm.
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
Performance:
- Treatment parse p95 is no more than 10 ms above its paired control.
- Treatment render p95 is no more than 15 ms above its paired control.
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
Isolation:
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
- The service respects queue, timeout, idle, and heap bounds.
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
## Phased implementation plan
### Phase 0 — measurement harness (completed in this change)
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
- Capture representative and stress baselines.
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
### Phase 1 — desktop and runtime service behind a switch
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
- Reuse current scanner/cache implementations.
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
- Keep the worker path as the disabled-switch fallback.
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
- Add E2E fault injection that kills the service during active terminal typing.
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
### Phase 2 — make the desktop/runtime process the default
- Enable the process by default in development, E2E, and canary builds.
- Collect process RSS, restart, timeout, and latency telemetry without content.
- Validate Windows, Linux, WSL, and constrained-runner results.
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
Exit: no unexplained crash or timeout increase and all acceptance gates green.
### Phase 3 — SSH relay sidecar
- Add the relay sidecar entry and supervisor.
- Remove scanner/parser/cache imports from the relay handler.
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
- Make inline relay scanning impossible; sidecar failure returns a host issue.
Exit: remote typing and fault gates green; current public relay methods unchanged.
### Phase 4 — renderer quiet publication
- Add the shared quiet-publication gate and React transition.
- Retain only the newest pending result per scope/host.
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
### Phase 5 — hardening and cleanup
- Remove the worker implementation after one stable release if the kill switch was unused.
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
- Document operational recovery and service diagnostics.
Exit: kill switch and dead worker code removed only after evidence supports it.
## Rollback
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
- Renderer: disable quiet publication and apply the validated complete result directly.
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
## Confidence and remaining questions
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
The remaining implementation questions are deliberately narrow:
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 28m">
<title>downloads: 28m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
<title>downloads: 31m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">28m</text>
<text x="90" y="14">28m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">31m</text>
<text x="90" y="14">31m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

-442
View File
@@ -1,442 +0,0 @@
# Mobile Relay UX — Investigation Findings & Fix Plan
Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over
the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes
desktop or relay-server code. All file references are in `mobile/` of this worktree.
## 1. Symptom → root-cause summary
| # | Symptom | Root cause (verified) |
|---|---------|----------------------|
| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` |
| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication |
| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible |
## 2. Verified end-to-end causal chains
### S1 — Resume dead-ends on the host index page
1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'`
(`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card
inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift
under the thumb.
2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting
`/h/[hostId]/session/[worktreeId]`.
3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves
the push to the host stack's **index route with no `hostId` param**. This exact failure mode is
documented twice in-repo ("cold Expo deep links resolve to index" —
`src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the
root cause named by PR #12001.
4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`.
`HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns
`state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**.
5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and
every fetch no-ops on `!client || connState !== 'connected'`
(`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are
static toolbar UI, so the page looks "real" but dead.
6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by
navigation history.
Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not
the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with*
a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than
it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration
(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`),
so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it.
**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through
the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`.
Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation
(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4),
catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9).
### S2 — grey blink when focusing a healthy relay host
1. Every focus of the host screen fires `notifyForeground()`
(`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps).
2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)`
(`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe
(`src/transport/mobile-endpoint-lifecycle.ts:62-64`).
3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls
`RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`).
4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session**
(`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns
unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink.
5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes
`'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot
(`src/components/StatusDot.tsx:11`), worktree queries blocked.
6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state
**stays 'disconnected'**: `migrateTo` only binds the new session's state after
`waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the
dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`)
fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial
delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`).
7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun.
Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live
client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`)
— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host.
Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the
network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The
asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a
connected direct client runs an activity probe that detects a half-open socket in ≤8s
(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground`
(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is
suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls
`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink
(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`).
Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber,
`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a
missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey
during the async client acquire (Keychain read) that home never shows.
### S3 — silent 5–10s relay-forced pairing
Pairing phase:
1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing`
(`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`).
2. The coordinator threads it **only to the direct candidate**
(`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate
(`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all
(`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution,
journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`.
3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its
12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE
handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write
(`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for
where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it.
4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director
resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped
at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of
silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the
director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay
request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`).
5. Pairing logs also never reach `connectionLogStore` (single producer:
`src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a
pairing that just failed.
Post-pairing phase:
6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/<id>`
(`app/pair-confirm.tsx:118-123`).
7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the
Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first**
(`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s`
(`src/transport/rpc-client.ts:126`) on a black-holed LAN.
8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live
progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor
start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`).
9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism
as S2) → green. Worst case with a director resolution failure and grace-credential retry:
~29–58s under the old session's labels.
10. The "Orca Relay" path label only renders once `state === 'connected'`
(`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only
after the wait ends, and `classifyConnection` has no relay-aware branch
(`src/transport/connection-health.ts:45-100`).
## 3. Anti-pattern sweep
### (a) Uncoordinated deep pushes into `/h` from outside the host stack
Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and
Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the
root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks
proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`).
Bare pushes remaining (host stack plausibly cold at each):
| Call site | Target | Cold scenario |
|---|---|---|
| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h/<id>/session/<wt>` or `/h/<id>` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener |
| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 |
| `app/index.tsx:835` (Account-usage card) | `/h/<id>/accounts` | Home is the root route |
| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes |
| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first |
Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination.
No `<Link>`, `navigationRef`, or `router.navigate` anywhere in `mobile/`.
Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit
host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight
(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts
(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount
can be destroyed by the gate cycle.
### (b) Surfaces that render grey 'disconnected' during expected transients
Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing
entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound
fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's
`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also
refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved
locally what every other surface gets wrong.
The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass)
**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold
start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical
client is not the cause — it already reports `'connecting'` synchronously by the time `connect()`
returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the
async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before
amber**.
Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via
`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled
(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for
`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner
(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty
state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all,
`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat
composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history /
diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log
screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick
Action gates all read `=== 'connected'`; voice settings goes fully inert
(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts
screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`).
Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on
focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` —
correct per billing, but state stays grey through the entire foreground re-dial rather than
flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the
pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths
(`:107,114,135`).
Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**:
`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect;
`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets;
`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent
lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` →
`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch
(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is
wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read
only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s
entry means an empty remount.
Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to
"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863`
and `tasks.tsx:10970`).
### (c) Invisible relay establishment phases
- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay
session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the
supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`).
- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states
(`src/transport/stable-logical-rpc-client.ts:182-223,267-299`).
- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`,
`mobile-relay-direct-upgrade-controller.ts:19`).
- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`.
- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`);
`classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch.
- Regression suite for connect-label stalls exists for the direct path only
(`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent.
## 4. Fix plan
Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire
protocol, so every old/new phone × old/new desktop pairing keeps working unless noted.
### F0 (S1, quick win) — land PR #12001 ✅ MERGED
Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check
against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and
auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR
branch. The PR routes Resume through the shared mount-then-replace mechanism
(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the
bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the
extracted module.
Backward compat: navigation-only, none.
Residuals tracked as F4/F7/F8/F9.
### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch)
Approach: split the nudge reasons that today all funnel into `setForeground(true)`:
- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either
no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short
budget) and only enter recovery on failure — mirroring the direct path's activity probe.
- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement**
instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path
(proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the
replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying
green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is
still `'connected'`, so the fallback suspend is required for convergence).
Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`),
`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend),
`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged
`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter.
Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing
`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already
accepts this). Half-open regression risk is covered by the fallback suspend.
Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot
stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the
background-suspend test unchanged.
### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch)
Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or
pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every
host screen with home's `?? 'connecting'`. Two companion changes in the same class:
- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving
the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button
currently drives the UI grey before amber.
- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the
cold-start gap (`client-context.tsx:87-153`) is amber too.
**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to
`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/
`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without
extending that predicate would silently disable the stale-list fallback and blank the list —
extend it to every not-connected state (or key it on "no live fetch has succeeded this mount").
Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`,
`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate),
`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead
of `null`).
Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates —
acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that
key on `'disconnected'` specifically.
Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence;
host screen test for the stale-list fallback under `'connecting'`.
### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch)
Approach: add an optional `onLog` to `connectMobileRelayForPairing`,
`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread
`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay:
resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated",
"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore`
under the resolved host id so the Connection Log screen has a record post-pairing.
Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`,
`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`.
Risk: none (additive, phone-local). Old desktops: unaffected — logging only.
Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`;
extend `pairing-relay-candidate.test.ts` for per-attempt lines.
### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes
Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`)
and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts`
once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted
follow-up). `orca://` deep links can follow later via a route-level guard.
Risk: notification cold-start ordering (push before root nav ready) — the mechanism already
tolerates that by waiting for state commits.
Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target.
### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo`
Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the
dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on
success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a
still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI:
show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let
`MobileHostCard`/`classifyConnection` render it while not yet connected.
Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`,
`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`.
Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the
forwarding strictly gated on suspended/disconnected.
Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases:
forwarded states during suspended dial, no forwarding during live-session replacement, failure
restores `'disconnected'`.
### F6 (S3, deeper) — happy-eyeballs relay start post-pairing
Approach: when a relay credential bundle exists and the direct dial has not authenticated within a
short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure;
first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to
the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay
sockets on healthy LANs.
Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a
phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so
the path is naturally guarded).
Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex
(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too
broadly.
Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a
relay socket; relay loser closed after direct wins.
### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce
Approach: (1) on Resume tap with the host connected, validate the target against the freshest
`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead.
(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s
(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`)
to the host index with a notice. (3) Use the validating reader in
`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse`
(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites.
Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss.
Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel
exemptions.
### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift
Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its
late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately
from the snapshot in a disabled state until the host connects.
Files: `app/index.tsx` footer.
Risk: none.
Tests: render test asserting footer order/height stability across `resumeWorktree` arrival.
### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack
Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending
spinner instead of replacing children, preserving in-flight nested navigation; keep the hard
replace only for the `blocked` verdict.
Files: `src/components/HostProtocolGate.tsx`.
Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an
overlay must still block interaction until resolved; verify child mount effects don't fire gated
RPCs pre-verdict before choosing overlay vs. current behavior.
Tests: gate test asserting no unmount across `statusPending` for an already-mounted host.
### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips
Approach: audit the §3(b) destructive-clear sites and make each preserve data across a
not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt
impact: git history blanking rows on the reconnect branch
(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo
cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on
error); the diff-review "ready-state preserved" branch that is dead code because it sits after the
early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe
(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration
(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount
(`app/h/[hostId]/index.tsx:129,330`).
Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts
rather than inventing new indicators. The in-repo reference pattern is
`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as
"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable`
preserves proven counts across a failed refresh, flagging only `staleCounts`.
Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger*
(suspend blips), so this is hardening, not the primary fix.
## 5. Backward compatibility (old/new phone × old/new desktop)
- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing
steps change. Old phones against any desktop are untouched (they don't have the code).
- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths
never activate; pairing keeps the existing `method_not_found` downgrade
(`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay
candidate is created).
- New phone + old desktop with relay: all paths use existing RPCs (`status.get`,
`pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses
the same resume-credential flow lease rotation already exercises against production desktops.
- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today.
## 6. Constants appendix (verified)
| Constant | Value | Where |
|---|---|---|
| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` |
| Direct handshake timeout | 5s | `rpc-client.ts:127` |
| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` |
| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` |
| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` |
| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` |
| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` |
| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` |
| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` |
| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` |
| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` |
| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` |
| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |
@@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
app itself still launches on stock 20.04.
**3. Check before loading, on hosts that ship without a compiler (`orcad`).**
The two gates above protect the packaged desktop app, where the binary is built and
verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it
adds a runtime precondition
([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)),
run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child
process**, so a binary the loader refuses — or one that aborts outright — is data rather
than this process's death, and the operator gets a sentence naming the host's libc, its
Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78
(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported
as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it
finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`.
**4. Ship the binary, built from patched sources.**
[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs)
(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current
host and files it under `out/orcad/prebuilds/<slot>/`, where a slot is
`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name
because node-pty's own loader falls back to `prebuilds/<platform>-<arch>` and cannot tell
glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`.
The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not
applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather
than a first-connect error. CI runs it once per slot inside the matching container
(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with
a hole in the matrix.
## Adding or upgrading a native dependency
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
+58
View File
@@ -0,0 +1,58 @@
# macOS press-and-hold and key repeat
macOS opens the accent picker when a key is held unless an application opts out in its preferences
domain. That prevents held keys from repeating in terminal applications such as vim. On the first
eligible launch, Orca writes:
```sh
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
```
The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
on the next launch.
## Precedence and decision record
Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
user choice and preserved. Only an unset key receives the `false` default.
The decision is stored once in
`<userData>/macos-press-and-hold-default.json`. An `applied` or
`kept-user-preference` decision prevents future launches from touching the domain again.
Probe and write failures remain retryable so a transient failure does not permanently disable the
fix.
`defaults read <domain> <key>` is used instead of
`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
timeouts, and other exit statuses leave the preference alone.
## Restoring the accent picker
Set the preference explicitly, then restart Orca:
```sh
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
```
After Orca has recorded its one-time decision, deleting the key also restores the macOS default
without Orca recreating it:
```sh
defaults delete com.stablyai.orca ApplePressAndHoldEnabled
```
Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
domain instead when applicable.
## Reverting
Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
must also arrange to delete the key for users who ran an affected build.
## Test coverage
Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
+190
View File
@@ -0,0 +1,190 @@
# Running orcad
`orcad` is the Orca runtime served from plain Node. This is the contract between it and
whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its
readiness payload actually proves.
Design background: `docs/design/shipping-orcad.html` §00c and §04.
## Two long-lived processes, not one
A deployment is **orcad** plus **the terminal daemon**.
| | orcad | terminal daemon |
| --- | --- | --- |
| Started by | the supervisor | orcad, detached |
| Owns | RPC, git, worktrees, persistence | every local PTY |
| Lifetime | one supervised run | **outlives orcad** |
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
The daemon outliving orcad is the property the whole peer model is recommended for
(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
restart, so a restart, an update or a rollback does not destroy running work. Everything
below exists to keep that true.
**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never
`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's
`KillMode=control-group` — kills the daemon too and turns every restart back into data loss.
Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group.
## Bind policy
`--bind <literal-ip>`, **default `127.0.0.1`**.
Only literal IPs are accepted; hostnames are refused because DNS would decide which
interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit
opt-ins to network reach, and the startup log says so on every launch.
The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own —
`orca serve`'s wide default, and a startup where some device has connected before — and an
unattended host's exposure must be exactly what the operator asked for on every launch. A
mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is
pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint
nothing can reach.
Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so
loopback is the correct default and the pairing credential travels over SSH.
## Data root and the instance lock
The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
Before the profile index or the store is touched, orcad takes `<data-root>/orcad.lock`.
It refuses to start when:
| Code | Meaning |
| --- | --- |
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
| `orcad_instance_lock_held` | another live orcad owns this root |
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
A root that is merely too permissive and that we own is tightened to `0700` rather than
refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
is a private root, and refusing when we could just fix it helps nobody. We refuse when the
permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are
not expressible as a POSIX mode, and `statSync().mode` there reports a synthesized one.
A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not
read as alive). A record belonging to a different identity is never reclaimed.
**The lock scopes one role — who is the runtime.** It deliberately says nothing about the
daemon, which lives under `<data-root>/daemon` and fences its own endpoint with its own PID
record. A lock that asked "is any process using this root" would refuse exactly the restarts
a live daemon makes worthwhile.
## Supervision
### Who supervises orcad
An external supervisor (systemd, launchd, a process manager). orcad conforms to it:
- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published
after the listener is bound and the daemon verdict is in. There is no separate readiness
socket; the line is the signal. Set the supervisor's start timeout generously — the daemon
launch has its own retries and can take tens of seconds on a cold host.
- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits
immediately with code 1 rather than being swallowed — a supervisor's second signal means
its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`,
the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline
and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
- **Exit codes.**
| Code | Meaning | Supervisor should |
| --- | --- | --- |
| 0 | clean shutdown | restart per policy |
| 1 | startup or shutdown failure | restart with backoff |
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
root owned by someone else is a restart-spin, not a recovery.
- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
its own NDJSON lifecycle log to `<data-root>/logs/daemon.log` (suppressed by
`ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see
[What is not covered](#what-is-not-covered).
### orcad supervising the daemon
- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
record, token and socket under `<data-root>/daemon`.
- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no
live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
to live work.
- **Restart.** The adapter respawns the daemon on death, transparently to callers.
- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run;
past that, launches are refused with `daemon_crash_loop` and terminals fail with that
message instead of the process forking forever. The window slides, so a repaired host
recovers without restarting orcad. An operator-initiated daemon restart clears it — that
is the deliberate "try again".
- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login
session dies. An orcad daemon must survive its SSH session ending.
- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself
after its adoption window; an adopted one stays resident (see Decommissioning).
### Decommissioning
The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with
zero Orca processes. A daemon that has been adopted stays resident after its runtime
disconnects — that is what makes the next start a reattach rather than a cold restore. To
retire a host completely, stop orcad and then stop the daemon named by
`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale.
## Health
The readiness payload carries a `health` object:
```
buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version
string cannot give, so a rollback that did not replace the file is visible
buildVersion ORCA_VERSION
nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match
platform / arch / pid
terminalDaemon:
state live | degraded | absent
ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart
pid the live daemon's pid, from its own PID record
buildVersion the build the LIVE daemon was forked from (may legitimately predate
this orcad after an update — reporting orcad's version for both would
hide exactly that)
entryPath / protocolVersion
selfTest { ok, coverage, verdict, durationMs }
```
### What the self-test proves
`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the
daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a
real short-lived PTY spawned inside the daemon's own process**. It therefore spans both
processes: orcad drives it, the daemon performs it, the verdict crosses the socket.
- `coverage: 'pty-spawn'` — the full round trip above.
- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without
spawning anything. A green verdict there covers the handshake and nothing more. It is
reported separately rather than folded into `ok` so nobody reads it as a PTY round trip.
`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A
daemon that answers but has fallen back to local spawning for new terminals is `degraded`,
because those terminals die with orcad. A daemon that answered and then failed its spawn
probe is also `degraded`, not `absent`: it still holds live sessions, and calling those
exited would be the verdict `ssh-execution-boundary.md` forbids guessing.
## What is not covered
Named here so nothing reads as implemented that is not:
- **A continuous health endpoint.** `health` is published once, in the readiness payload. A
supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same
`collectOrcadHealth()`; that surface does not exist yet.
- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy
(plan item 5), which owns libc detection; there is no honest value to publish until then.
- **`degradations[]`.** Plan item 2's contract, not this one.
- **Credential administration** (list / revoke / rotate devices, expiring pending offers,
structured security logging) — §04, not delivered here.
- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on
conflict.
- **Reconciling `webClientUrl` with reachability** under the loopback default.
- **State-schema rollback rules.**
- **Daemon log rotation.** `<data-root>/logs/daemon.log` grows unbounded.
@@ -138,3 +138,53 @@ error payload and read that instead. An old host omits it and the message match
covers them; once hosts that send it are the floor, the message match can be deleted
rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code`
without doing this reinstates the bug — the transport has already overwritten it.
## Known hazard: clients ignore host-published failure fields on client-placed pages
`RuntimeMobileSessionBrowserTab` — the browser tab a host publishes on the session-tab sync
channel — permits `placement`, `loadError` and `certificateFailure` together. But for a tab
whose `placement.kind` is `'client'` the engine runs in the client's own app: the failure is
raised by the local guest webview, and the host has no view of it (`RuntimeBrowserClientPage`,
what the registry actually publishes from, carries neither field). Clients from
this version on therefore refuse host ownership of both records for client-placed pages
(`web-session-tabs-sync.ts`, the `placement?.kind !== 'client'` carve-outs) — without that,
each metadata snapshot deletes the locally recorded failure and the page's failure overlay
disappears mid-navigation.
The hazard is forward-facing and Rule 3 shaped. A host that later starts publishing
`loadError` or `certificateFailure` for a client-placed page reaches these clients as content
they silently drop, so the host would see no error and no effect. Publishing it has to be
capability-gated, with the carve-out narrowed to clients that did not negotiate the
capability. Note the cross-version harness does not exercise the session-tab sync channel, so
nothing fails if this is forgotten — this note is the only record.
A related carve-out covers `title`, `url`, `loading`, `canGoBack` and `canGoForward`
(`resolveMirroredBrowserPageContent`), and for those the hazard is already live rather than
forward-facing: the host does publish them, from a `RuntimeBrowserClientPage` it can only learn
about second-hand through the client's own `browser.clientHost.pageMetadata` calls. Its copy
therefore starts at the registry defaults (`'Browser'`, the create-time url), and while those
publishes are failing it never leaves them.
That copy is not simply behind, though, and a client must not treat it as such. When a lease
reattaches, the host refreshes the page from the client host's own inventory
(`runtime-browser-client-page-recovery.ts`), which reads the live guest — so it can be strictly
fresher than a local row whose pane is unmounted and whose metadata publisher was disposed with
it. A client that ignores the host url is relying on its own guest to re-answer on remount,
which `ClientHostedBrowserPagePane`'s mount-time `syncNavigation` is what makes true.
These five are therefore refused only by the client whose guest actually runs the page:
`placement.browserHostClientId` is compared against this client's own host id
(`readBrowserClientHostId`). Main stamps that id into the guest-hosting window's
`additionalArguments` at creation, and the preload reads it back out of its own argv — the answer
has to be there before the first snapshot is interpreted, which is earlier than any IPC handler a
renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
the host, which is the only reason a mirrored viewer shows anything but its first snapshot
forever. Improving what a *second* client sees still means fixing the publish, not the carve-out;
the carve-out no longer stands in the way of it.
The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'`
predicate. It is unobservable today — the host publishes neither field for a client-placed page at
all, so a mirror has nothing to take either way. If the capability-gated publish this section
anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should
take a failure it cannot otherwise see, and only the hosting client should refuse it.
+151 -1
View File
@@ -37,9 +37,147 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
| + memory + command line | 30.6 ms | 33.7 ms |
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
Those CIM numbers are from a 1050-process host. The scan scales with process
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
**4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits
match the pre-#15749 reader, so relay hosts are at parity rather than newly at
risk — but the headroom is roughly 2x on time and 1.7x on bytes, not the ~4x the
706 ms figure implies. On overflow the output is truncated, the JSON fails to
parse, and the read rejects, so a busy host loses the table rather than
receiving a wrong one.
## When a read wedges
The vendored reader pushes every callback onto a module-global queue and drains
that queue only when the request holding its `requestInProgress` latch
completes. If a Toolhelp32 snapshot never comes back — an EDR hook, a restricted
token, a worker that dies — the latch is stuck for the life of the process and
every later call parks another closure in that queue.
Two guards, and they work together:
- a **3 s deadline** on each read, so a caller gets a rejection instead of a
promise that never settles;
- a **sticky wedge**: once a read misses its deadline and has not called back,
the module refuses every further read until that read's callback fires.
The wedge used to be a 30 s cooldown that let one probe through per window. That
bounded the *rate* of new callbacks but not the total: a permanently wedged
reader retained one more closure every 30 s for as long as the app ran, and each
probe also blocked its caller for the full 3 s deadline first. Gating on the
outstanding read instead bounds retention at exactly one callback, and gives up
nothing on recovery — a probe queued behind the latch could never have observed
recovery anyway, whereas the stuck callback firing is the drain itself. On the
relay's bare addon, which has no queue of its own, it is also what keeps Orca
from re-entering `CreateToolhelp32Snapshot` while a call is still running.
That last part is not just tidiness. The addon runs each read as a
`Napi::AsyncWorker`, so a wedged read holds a libuv threadpool slot for good. On
the relay the JS queue is not there to absorb the retries, so one probe per
window would have pinned all four default threads inside ~2 minutes — hanging
every async `fs` and DNS call in that process, not only the process table.
A wedge does **not** engage the PowerShell fallback; see the next section for
why only absence does.
## The relay has no binding, and falls back
Relay deployment installs only `node-pty` and `@parcel/watcher` on the remote
host (`RELAY_NATIVE_DEPS` in `src/main/ssh/ssh-relay-deploy.ts`), so a Windows
machine used as an SSH host has no `@vscode/windows-process-tree` at all. It is
not added there on purpose. Both ways of installing it fail, and both were
checked on a real Windows SSH host with 1486 processes:
**Installing it normally rebuilds from source, and that build fails.** The
tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
what is already compiled inside it. On a host that *already had* MSVC Build
Tools 2022 installed, that build still failed:
```
error MSB8040: Spectre-mitigated libraries are required for this project.
```
That is the requirement the `binding.gyp` hunk of our patch deletes, and the
patch cannot reach a remote host — pnpm patches do not cross SSH. Relay deploy
would then break outright rather than degrade: `installNativeDeps` throws on
failure, and the toolchain-skip retry is gated to Linux.
**Skipping the build and using the shipped binary returns a truncated table.**
Contrary to what this file used to claim, the published 0.8.0 tarball *does*
contain `build/Release/windows_process_tree.node` — an MSVC build directory that
looks accidentally published (`.obj` and `.tlog` files ship with it). It is
N-API, so it loads on any modern Node. But it predates our patch and still has
the `process_count < 1024` cap, so on that 1486-process host:
```
LOADED OK
rows=1024
selfPid=21964 present=false
```
Exactly 1024 rows, with the querying process itself among the missing. The
self-presence guard rejects that, so the fallback engages anyway — but only on
hosts busy enough to cross the cap. That is worse than no binding at all: it
works on a quiet machine and fails silently under load, which is precisely the
shape of bug that survives testing.
So the constraint is not that no binary exists to ship. It is that the only
binary available to ship is the broken one, and building the good one needs a
toolchain the remote does not have.
Instead, `windows-process-table.ts` falls back to
`readWindowsProcessRowsWithCim` (`windows-process-table-cim-scan.ts`), the
`Get-CimInstance` scan this module replaced. The gate is deliberately narrow:
- it engages **only** when the module cannot be required, never when a loaded
module fails, wedges, or returns an unreadable table — a present-but-failing
reader must not silently start forking a shell at the caller's poll rate;
- a fallback that also fails still rejects, so "unavailable" never degrades into
"nothing is running";
- the scan applies the same self-presence guard as the native path.
`src/main/ssh/relay-native-dependency-coverage.test.ts` asserts that every
native addon reachable from the relay entry is either installed on relay hosts
or listed there with the reason its absence is safe. That test exists because
#15749 shipped this gap: the relay tests injected a fake module through
`__setWindowsProcessTreeLoaderForTests`, so nothing exercised the real require.
## Shipping the native reader to a relay anyway
The scan is the floor, not the destination: it costs ~1.4 s and a `powershell.exe`
where the addon costs ~57 ms. Release builds therefore compile the addon and ship
it as an optional relay artifact.
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
source pnpm has already patched, on a Windows runner, and refuses to run if
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
hunk fails *silently*, and the relative gyp path dies at configure on Windows.
The source is checked rather than the install trusted. It also reads the PE
machine field of the output, because a cross-build that quietly emitted host
arch would ship a binary the target cannot load.
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
optional *MSVC v143 ARM64 build tools* component; without it node-gyp fails with
`MSB8020`, which is why the addon build runs before the long packaging step.
`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
added best-effort before it is promoted to required.
`windows-process-table.ts` binds the bare addon directly rather than the package
wrapper. That wrapper adds only a queue over `getProcessList`, and that queue is
the wedge described above — it latches a module-global `requestInProgress` with
no try/catch. This module already holds a single-flight and a deadline, so going
straight to the addon drops the duplicate.
The artifact is optional in `RELAY_ARTIFACTS`: hashed when present, so a relay
carrying it never shares an immutable directory with one that does not, and
never probed, because requiring a file only a Windows build machine can produce
would make a correct relay read as MISSING and redeploy forever. A relay built
on any other OS keeps using the scan.
## Why the package is patched
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks.
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries three hunks.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
@@ -49,6 +187,11 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
1024 and the querying process was itself among the 27 missing. A truncated
snapshot silently hides the descendants a teardown is trying to reap — the
exact failure the native path exists to remove.
3. **Absolute `node-addon-api` gyp path.** `require('node-addon-api').targets`
is cwd-relative. node-gyp on Windows evaluates it from the pnpm store
realpath, then loads the relative path from the `node_modules` symlink, so
`node_addon_api.gyp` resolves outside the repo and hourly Windows builds
die at configure. `node-pty` is patched the same way for the same reason.
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
not: the longest observed on a real host was 26,059.
@@ -73,6 +216,13 @@ time to prove a PID has not been recycled — daemon identity, managed-hook
ownership, and CPU accounting in the memory collector — still reads it through
its own query. Those callers are not migrated.
Committed private bytes have no equivalent either, and the one memory value the
snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
second reason `windows-process-resource-collector.ts` still runs its own
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
counters in the same pass. Migrating it to the native table would cost both.
Start time is a proxy for identity, not identity. The durable answer for the
process trees Orca itself spawns is an inherited handle: a job object names the
tree Orca created, so no start-time comparison is needed. Those readers should
+2 -7
View File
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
@@ -239,12 +240,6 @@ export const electronViteConfig: UserConfig = {
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
// Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
// spawnSync — codex app-server trust grants need a live event loop
// but must finish before a Codex pane launch proceeds.
'codex/codex-app-server-grant-entry': resolve(
'src/main/codex/codex-app-server-grant-entry.ts'
),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
@@ -285,7 +280,7 @@ export const electronViteConfig: UserConfig = {
preload: {
build: {
externalizeDeps: {
exclude: ['@electron-toolkit/preload']
exclude: ['@electron-toolkit/preload', 'zod']
}
}
},
+12
View File
@@ -46,3 +46,15 @@ index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebf
AttributedString::Range selectionRange;
// ScrollView-like metrics
Size contentSize;
diff --git a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
index 79ee7ffe43..956f189a1c 100644
--- a/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm
@@ -136,6 +136,7 @@ using namespace facebook::react;
- (void)prepareForRecycle
{
[super prepareForRecycle];
_textView.state = nullptr;
+ _textView.layoutMetrics = EmptyLayoutMetrics;
_accessibilityProvider = nil;
}
+1 -1
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
react-native@0.83.9:
hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e
hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d
path: patches/react-native@0.83.9.patch
importers:
+5 -2
View File
@@ -7,7 +7,10 @@ import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-mo
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names'
import {
buildRetiredWorktreeNamesRefreshKey,
useRetiredWorktreeNames
} from '../worktree/use-retired-worktree-names'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
import {
getMobileWorkspaceRepoBadgeColor,
@@ -89,7 +92,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
detectedAgentIds: executionTarget.detectedAgentIds
})
const retiredNamesRefreshKey = useMemo(
() => (existingWorktreePaths ?? []).toSorted().join('\0'),
() => buildRetiredWorktreeNamesRefreshKey(existingWorktreePaths),
[existingWorktreePaths]
)
const retiredWorktreeNames = useRetiredWorktreeNames(
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
StyleSheet: { create: <T>(styles: T) => styles },
View: 'View'
}))
import { colors } from '../theme/mobile-theme'
import { statusDotColor } from './StatusDot'
describe('statusDotColor', () => {
it('keeps Relay progress amber when the raw state is disconnected', () => {
expect(statusDotColor('disconnected', { kind: 'normal', label: 'Connecting via Relay…' })).toBe(
colors.statusAmber
)
})
it('keeps an idle disconnected host gray', () => {
expect(statusDotColor('disconnected', { kind: 'normal', label: 'Disconnected' })).toBe(
colors.textMuted
)
})
})
+11 -6
View File
@@ -12,6 +12,16 @@ const stateColors: Record<ConnectionState, string> = {
'auth-failed': colors.statusRed
}
export function statusDotColor(state: ConnectionState, verdict?: ConnectionVerdict): string {
if (verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed') {
return colors.statusRed
}
if (verdict?.kind === 'warning' || (verdict?.kind === 'normal' && verdict.label.endsWith('…'))) {
return colors.statusAmber
}
return stateColors[state] ?? colors.textMuted
}
// Why: when caller passes a verdict, the dot color reflects the verdict's
// severity instead of the raw transport state. This avoids the "amber dot
// next to red 'Can't reach desktop' label" mismatch — the underlying
@@ -24,12 +34,7 @@ export function StatusDot({
state: ConnectionState
verdict?: ConnectionVerdict
}) {
const color =
verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed'
? colors.statusRed
: verdict?.kind === 'warning'
? colors.statusAmber
: (stateColors[state] ?? colors.textMuted)
const color = statusDotColor(state, verdict)
return <View style={[styles.dot, { backgroundColor: color }]} />
}
@@ -0,0 +1,65 @@
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
blankStringContents,
blankStringContentsDesynced,
isTestFile,
scanSourceTree,
stripComments,
type ScannedFile
} from '../../src/shared/source-scan/source-tree-scan'
const SOURCE_ROOTS = [
{ label: 'mobile/src', path: import.meta.dirname },
{ label: 'mobile/app', path: resolve(import.meta.dirname, '../app') },
{ label: 'src/shared', path: resolve(import.meta.dirname, '../../src/shared') }
]
const UNSUPPORTED_ARRAY_SORTING = /\.toSorted\s*\(/
function usesUnsupportedArraySorting(source: string): boolean {
if (!UNSUPPORTED_ARRAY_SORTING.test(source)) {
return false
}
const decommented = stripComments(source)
return (
blankStringContentsDesynced(decommented) ||
UNSUPPORTED_ARRAY_SORTING.test(blankStringContents(decommented))
)
}
function findUnsupportedArraySorting(files: readonly ScannedFile[]): string[] {
return files
.filter((file) => !isTestFile(file.relativePath))
.filter((file) => usesUnsupportedArraySorting(file.source))
.map((file) => file.relativePath)
}
describe('Hermes array sorting compatibility', () => {
it('detects live copied-array sorting while ignoring inert text and tests', () => {
expect(
findUnsupportedArraySorting([
{ path: 'live.ts', relativePath: 'live.ts', source: 'const sorted = items.toSorted()' },
{
path: 'inert.ts',
relativePath: 'inert.ts',
source: "// items.toSorted()\nconst example = 'items.toSorted()'"
},
{
path: 'allowed.test.ts',
relativePath: 'allowed.test.ts',
source: 'items.toSorted()'
}
])
).toEqual(['live.ts'])
})
it('keeps production mobile bundle sources free of unsupported copied-array sorting', () => {
const offenders = SOURCE_ROOTS.flatMap(({ label, path }) =>
findUnsupportedArraySorting(scanSourceTree(path, { includeTests: true })).map(
(relativePath) => `${label}/${relativePath}`
)
)
expect(offenders).toEqual([])
})
})
+6 -9
View File
@@ -11,7 +11,10 @@ import {
} from '../onboarding/mobile-onboarding-plan'
import { totalHomeStats, type HomeStatsSummary } from '../stats/home-stats-total'
import type { TaskProvider } from '../tasks/mobile-task-providers'
import { selectConnectableHostProfiles } from '../transport/host-catalog-selection'
import {
selectConnectableHostProfiles,
sortHostsByLastConnected
} from '../transport/host-catalog-selection'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry, HostProfile } from '../transport/types'
import { fetchHomeHostWorktreeInfo } from '../worktree/home-host-worktree-fetch'
@@ -127,14 +130,8 @@ export function useMobileHomeData() {
}, [router])
)
const sortedHosts = useMemo(
() => hosts.toSorted((left, right) => right.lastConnected - left.lastConnected),
[hosts]
)
const sortedHostCatalog = useMemo(
() => hostCatalog.toSorted((left, right) => right.lastConnected - left.lastConnected),
[hostCatalog]
)
const sortedHosts = useMemo(() => sortHostsByLastConnected(hosts), [hosts])
const sortedHostCatalog = useMemo(() => sortHostsByLastConnected(hostCatalog), [hostCatalog])
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const resumeCard = useMemo(
+2 -1
View File
@@ -181,12 +181,13 @@ export function MobileNativeChatView({
const { data } = useMemo(
() =>
buildMobileNativeChatTransientData({
messages,
folded,
streaming,
pending,
imagePreviewsByMessageId
}),
[folded, streaming, pending, imagePreviewsByMessageId]
[messages, folded, streaming, pending, imagePreviewsByMessageId]
)
// Follow the tail as the conversation grows and keep the newest message above
@@ -125,10 +125,9 @@ describe('foldToolMessages', () => {
expect(folded.map((m) => m.id)).toEqual(['a0', 'a2'])
})
it('keeps a tool message standalone when no assistant precedes it', () => {
it('drops a tool message whose call is outside the loaded window', () => {
const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])])
expect(folded).toHaveLength(1)
expect(folded[0]!.role).toBe('tool')
expect(folded).toHaveLength(0)
})
it('does not merge across a user message', () => {
@@ -137,17 +136,18 @@ describe('foldToolMessages', () => {
msg('user', [{ type: 'text', text: 'q' }], 'u'),
msg('tool', [{ type: 'tool-result', output: 'r' }], 't')
])
expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool'])
expect(folded.map((m) => m.role)).toEqual(['assistant', 'user'])
})
it('folds a long tool run without mutating the source assistant', () => {
const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a')
const tools = Array.from({ length: 1_000 }, (_unused, index) =>
const tools = Array.from({ length: 1_000 }, (_unused, index) => [
msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], `c-${index}`),
msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`)
)
]).flat()
const folded = foldToolMessages([assistant, ...tools])
expect(folded[0].blocks).toHaveLength(1_001)
expect(folded[0].blocks).toHaveLength(2_001)
expect(assistant.blocks).toHaveLength(1)
})
})
@@ -21,3 +21,28 @@ export function clampFontScale(scale: number): number {
}
return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale))
}
/** Granularity a pinch is allowed to commit at. */
export const FONT_SCALE_STEP = 0.05
/** Snap a proposed font scale to the nearest committed step.
*
* Why quantize: the chat's `renderItem` closes over `fontScale`, so every
* distinct value re-renders and re-measures every message in the list. A raw
* pinch proposes a new scale on each gesture frame, so a single zoom drives
* hundreds of whole-list re-measures — and because the pinch is composed
* `Simultaneous` with the list's own scroll, an incidental second finger during
* a scroll starts that storm at scales indistinguishable from 1. Those
* re-measures run while rows are mounting and unmounting, which is when a
* recycled iOS paragraph view can repaint with the previous row's content
* frame and silently drop the tail of a long message. Snapping to steps keeps a
* full-range pinch to at most 20 commits and makes a near-neutral pinch commit
* nothing at all. */
export function quantizeFontScale(scale: number): number {
if (Number.isNaN(scale)) {
return 1
}
const stepped = Math.round(clampFontScale(scale) / FONT_SCALE_STEP) * FONT_SCALE_STEP
// Re-clamp: rounding can push the outermost step past the bound.
return clampFontScale(Number(stepped.toFixed(2)))
}
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
countUserTextOccurrences,
normalizeReconcileText
} from './mobile-native-chat-draft-reconcile'
import {
appendMobileNativeChatPending,
type MobileNativeChatSendOrigin
} from './mobile-native-chat-pending-echo'
import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement'
const NO_IMAGE_ECHOES: ReadonlySet<string> = new Set()
/** A send captured against `messages`, exactly as `captureSendOrigin` builds it. */
function sendOrigin(
text: string,
messages: readonly NativeChatMessage[]
): MobileNativeChatSendOrigin {
const normalizedText = normalizeReconcileText(text)
return {
draftKey: 'host\0worktree\0tab',
pendingKey: 'host\0worktree\0tab\0session',
normalizedText,
// Use production's counter so the test cannot mirror its normalization drift.
baselineOccurrences: countUserTextOccurrences(messages, normalizedText),
baselineTailMessageId: messages.at(-1)?.id ?? null,
baselineResolved: true
}
}
function userTurn(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: 1000,
source: 'transcript'
}
}
describe('appendMobileNativeChatPending ordinals for repeated sends', () => {
const KEY = 'host\0worktree\0tab\0session'
// Multi-line text distinguishes raw trim from reconciliation normalization.
const MULTILINE = 'first line of the prompt\nsecond line of the prompt'
it('gives a repeated multi-line send the next ordinal', () => {
const baseline = [userTurn('m1', 'unrelated')]
const first = appendMobileNativeChatPending(
{},
KEY,
'p1',
sendOrigin(MULTILINE, baseline),
MULTILINE
)
const both = appendMobileNativeChatPending(
first,
KEY,
'p2',
sendOrigin(MULTILINE, baseline),
MULTILINE
)
expect(both[KEY]?.map((item) => item.expectedOccurrence)).toEqual([1, 2])
})
it('retires only the first echo when the first of two identical rows lands', () => {
const baseline = [userTurn('m1', 'unrelated')]
const pending = appendMobileNativeChatPending(
appendMobileNativeChatPending({}, KEY, 'p1', sendOrigin(MULTILINE, baseline), MULTILINE),
KEY,
'p2',
sendOrigin(MULTILINE, baseline),
MULTILINE
)[KEY]!
const landedOnce = [...baseline, userTurn('m2', MULTILINE)]
expect(
retireLandedMobileNativeChatPending(landedOnce, pending, NO_IMAGE_ECHOES).map(
(item) => item.id
)
).toEqual(['p2'])
})
it('still counts a single-line repeat, which never regressed', () => {
const baseline = [userTurn('m1', 'unrelated')]
const both = appendMobileNativeChatPending(
appendMobileNativeChatPending({}, KEY, 'p1', sendOrigin('ping', baseline), 'ping'),
KEY,
'p2',
sendOrigin('ping', baseline),
'ping'
)
expect(both[KEY]?.map((item) => item.expectedOccurrence)).toEqual([1, 2])
})
})
describe('mobile pending echoes whose text normalizes to nothing', () => {
const KEY = 'host\0worktree\0tab\0session'
// KNOWN GAP: marker-only rows render empty; suppressing their echo would hide the send.
it('cannot retire an echo whose row normalizes away', () => {
const stranded = [
{
id: 'p1',
text: '[Image #1]',
expectedOccurrence: 1,
baselineTailMessageId: 'm1',
baselineResolved: true
}
]
const landed = [userTurn('m1', 'hi'), userTurn('m2', '[Image #1]')]
expect(retireLandedMobileNativeChatPending(landed, stranded, NO_IMAGE_ECHOES)).toEqual(stranded)
})
it('gives a caption-less photo its own ordinal after a marker-only caption', () => {
const first = appendMobileNativeChatPending(
{},
KEY,
'p1',
sendOrigin('[Image #1]', []),
'[Image #1]',
['file:///a.jpg']
)
const both = appendMobileNativeChatPending(first, KEY, 'p2', sendOrigin('', []), '', [
'file:///b.jpg'
])
expect(both[KEY]?.map((item) => item.expectedOccurrence)).toEqual([1, 2])
})
it('records an image echo, whose text is empty by design', () => {
const pending = appendMobileNativeChatPending({}, KEY, 'p1', sendOrigin('', []), '', [
'file:///photo.jpg'
])
expect(pending[KEY]).toHaveLength(1)
expect(pending[KEY]?.[0]?.expectedOccurrence).toBe(1)
})
})
@@ -1,3 +1,5 @@
import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile'
export type MobileNativeChatPendingMessage = {
id: string
text: string
@@ -43,13 +45,17 @@ export function appendMobileNativeChatPending(
images?: string[]
): PendingByKey {
const current = previous[key] ?? []
// Count outstanding repeats with the same normalized key.
const earlierOutstanding = current.filter(
(pending) =>
pending.text.trim() === origin.normalizedText &&
normalizeReconcileText(pending.text) === origin.normalizedText &&
pending.expectedOccurrence > origin.baselineOccurrences
).length
// Image ordinal selection and counting must share the empty-text discriminator.
const expectedImageEchoOrdinal =
current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1
current.filter(
(pending) => normalizeReconcileText(pending.text) === '' && pending.images?.length
).length + 1
return {
...previous,
[key]: [
@@ -321,3 +321,27 @@ describe('retireLandedMobileNativeChatPending', () => {
expect(retireLandedMobileNativeChatPending(messages, pending, new Set(['p1']))).toEqual([])
})
})
describe('retireLandedMobileNativeChatPending on a PTY-typed send', () => {
// A TUI may record the leading Ctrl+U transport byte as prompt content.
const COMPOSER_TEXT = 'run the focused tests\nand summarize failures'
const TRANSCRIPT_ROW = `\u0015${COMPOSER_TEXT}`
it('retires the echo of a row the TUI pasted the Ctrl+U byte into', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', TRANSCRIPT_ROW, 5000),
assistantTurn('m3', 'on it', 6000)
]
const pending = [pendingSend('p1', COMPOSER_TEXT, 'm1')]
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual([])
})
it('still holds the echo when no row carries the text at all', () => {
const messages = [assistantTurn('m1', 'ready', 1000)]
const pending = [pendingSend('p1', COMPOSER_TEXT, 'm1')]
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual(pending)
})
})
@@ -32,6 +32,13 @@ describe('mobileNativeChatEmptyState', () => {
expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex')
})
it('invites a first message while the transcript file is still unwritten', () => {
// The spinner is already gone by then, so a bare list would read as broken.
expect(mobileNativeChatEmptyState('awaiting-transcript', 'claude')?.title).toBe(
'Start a chat with Claude'
)
})
it('falls back to "the agent" when the agent is unknown', () => {
expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe(
'Start a chat with the agent'
@@ -58,6 +65,7 @@ function build(
pending: Parameters<typeof buildMobileNativeChatTransientData>[0]['pending']
): NativeChatMessage[] {
return buildMobileNativeChatTransientData({
messages,
folded: foldMobileNativeChatMessages(messages),
streaming,
pending
@@ -136,6 +144,7 @@ describe('buildMobileNativeChatTransientData', () => {
user('prompt', '[Image #1] look at this')
])
const result = buildMobileNativeChatTransientData({
messages: folded,
folded,
streaming: null,
pending: [],
@@ -151,6 +160,7 @@ describe('buildMobileNativeChatTransientData', () => {
it('restores the local preview onto a marker-only transcript turn', () => {
const result = buildMobileNativeChatTransientData({
messages: [user('prompt', '[Image #1]')],
folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]),
streaming: null,
pending: [],
@@ -173,3 +183,250 @@ describe('buildMobileNativeChatTransientData', () => {
expect(data.some((message) => message.id === 'streaming')).toBe(false)
})
})
describe('foldMobileNativeChatMessages', () => {
function toolCall(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } }],
timestamp: 0,
source: 'transcript'
}
}
function toolResult(id: string, output: string): NativeChatMessage {
return {
id,
role: 'tool',
blocks: [{ type: 'tool-result', output }],
timestamp: 0,
source: 'transcript'
}
}
// The chat reads a 40-message tail, so the window regularly opens on a result
// whose `tool_use` is older than the window. It used to render as its own
// bubble of raw shell output with no owning call.
it('drops a leading tool result whose call is older than the read window', () => {
const folded = foldMobileNativeChatMessages([
toolResult('orphan', 'Exit code 1\norca-ide not found'),
assistant('a1', 'Falling back to the installed binary.')
])
expect(folded.map((message) => message.id)).toEqual(['a1'])
})
it('still folds a result whose call is inside the window', () => {
const folded = foldMobileNativeChatMessages([
assistant('a1', 'Checking which binary is on PATH.'),
toolCall('c1'),
toolResult('r1', 'orca-ide not found')
])
expect(folded).toHaveLength(1)
expect(folded[0]?.blocks.map((block) => block.type)).toEqual([
'text',
'tool-call',
'tool-result'
])
})
it('keeps a mixed Claude tool result with a harness sidecar paired', () => {
const folded = foldMobileNativeChatMessages([
toolCall('c1'),
{
id: 'mixed',
role: 'user',
blocks: [
{ type: 'tool-result', output: 'important output' },
{ type: 'text', text: '<system-reminder>continue</system-reminder>' }
],
timestamp: 0,
source: 'transcript'
}
])
expect(folded[0]?.blocks).toEqual([
{ type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } },
{ type: 'tool-result', output: 'important output' }
])
})
it('keeps a hidden interruption from authorizing a later result', () => {
const folded = foldMobileNativeChatMessages([
toolCall('c1'),
{
id: 'interrupt',
role: 'user',
blocks: [{ type: 'text', text: '[Request interrupted by user]' }],
timestamp: 1,
source: 'transcript'
},
toolResult('orphan', 'stale output')
])
expect(folded.map((message) => message.id)).toEqual(['c1'])
expect(folded[0]?.blocks).toEqual([
{ type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } }
])
})
})
// Claude consumes a mid-turn send through a `queued_command` attachment and writes
// no `type:"user"` record for it, so that echo has no row to match - ever. What
// made the conversation read as scrambled was not the unmatched echo itself but
// where it rendered: appended after every turn, so it re-read below each new one.
describe('buildMobileNativeChatTransientData anchoring', () => {
function row(id: string, role: 'user' | 'assistant', text: string): NativeChatMessage {
return { id, role, blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' }
}
it('keeps an unmatched echo where it was sent instead of below later turns', () => {
const folded = [row('m1', 'user', 'earlier'), row('m2', 'assistant', 'on it')]
const { data } = buildMobileNativeChatTransientData({
messages: folded,
folded,
streaming: null,
pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'm2', 'p1'])
// The agent keeps working. The echo must NOT drift below the new turns.
const later = [
...folded,
row('m3', 'assistant', 'still working'),
row('m4', 'assistant', 'done')
]
const { data: after } = buildMobileNativeChatTransientData({
messages: later,
folded: later,
streaming: null,
pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }]
})
expect(after.map((m) => m.id)).toEqual(['m1', 'm2', 'p1', 'm3', 'm4'])
})
it('reproduces the reported replay: stale echoes stay in place, not stacked at the tail', () => {
const folded = [
row('m1', 'user', 'first question'),
row('m2', 'assistant', 'answering'),
row('m3', 'assistant', 'newest turn')
]
const { data } = buildMobileNativeChatTransientData({
messages: folded,
folded,
streaming: null,
pending: [
{ id: 'p1', text: 'sent against m1', baselineTailMessageId: 'm1' },
{ id: 'p2', text: 'sent against m2', baselineTailMessageId: 'm2' }
]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'm2', 'p2', 'm3'])
})
it('keeps send order among echoes sharing one anchor', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'assistant', 'ready')],
folded: [row('m1', 'assistant', 'ready')],
streaming: null,
pending: [
{ id: 'p1', text: 'first', baselineTailMessageId: 'm1' },
{ id: 'p2', text: 'second', baselineTailMessageId: 'm1' }
]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'p2'])
})
it('falls back to the tail when the send captured no baseline', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'assistant', 'ready')],
folded: [row('m1', 'assistant', 'ready')],
streaming: null,
pending: [{ id: 'p1', text: 'no baseline yet', baselineTailMessageId: null }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1'])
})
it('falls back to the tail when the captured row left the raw window', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'assistant', 'ready')],
folded: [row('m1', 'assistant', 'ready')],
streaming: null,
pending: [{ id: 'p1', text: 'anchored to a folded-away row', baselineTailMessageId: 'gone' }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1'])
})
it('keeps an echo before later turns when its folded baseline leads the raw window', () => {
const messages = [
row('noise', 'user', '<system-reminder>hidden boundary'),
row('a1', 'assistant', 'arrived later')
]
const folded = foldMobileNativeChatMessages(messages)
expect(folded.map((message) => message.id)).toEqual(['a1'])
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
streaming: null,
pending: [
{ id: 'p1', text: 'sent after the hidden boundary', baselineTailMessageId: 'noise' }
]
})
expect(data.map((message) => message.id)).toEqual(['p1', 'a1'])
})
it('still puts the streaming bubble after the transcript', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'user', 'hi')],
folded: [row('m1', 'user', 'hi')],
streaming: 'thinking',
pending: [{ id: 'p1', text: 'echo', baselineTailMessageId: 'm1' }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'streaming'])
})
it('anchors a send captured against a tool row to its folded assistant', () => {
const messages: NativeChatMessage[] = [
row('a1', 'assistant', 'working'),
{
id: 'tool',
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm test' } }],
timestamp: 2,
source: 'transcript'
},
row('a2', 'assistant', 'done')
]
const folded = foldMobileNativeChatMessages(messages)
expect(folded.map((message) => message.id)).toEqual(['a1', 'a2'])
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
streaming: null,
pending: [{ id: 'p1', text: 'sent during the tool', baselineTailMessageId: 'tool' }]
})
expect(data.map((message) => message.id)).toEqual(['a1', 'p1', 'a2'])
})
it('anchors after the prompt that absorbed an earlier image-source row', () => {
const messages = [
row('a1', 'assistant', 'ready'),
user('source', '[Image: source: /tmp/earlier.png]'),
user('prompt', '[Image #1] earlier image'),
row('a2', 'assistant', 'done')
]
const folded = foldMobileNativeChatMessages(messages)
expect(folded.map((message) => message.id)).toEqual(['a1', 'prompt', 'a2'])
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
streaming: null,
pending: [{ id: 'p1', text: 'sent after the image source', baselineTailMessageId: 'source' }]
})
expect(data.map((message) => message.id)).toEqual(['a1', 'prompt', 'p1', 'a2'])
})
})
@@ -6,7 +6,10 @@ import {
import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
import {
isImageSourceUserTurn,
normalizeImageTranscriptMessages
} from './mobile-native-chat-image-transcript-markers'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** The centered empty-state copy for a chat with no messages, mirroring the
@@ -20,10 +23,11 @@ export function mobileNativeChatEmptyState(
): NativeChatEmptyStateCopy | null {
const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent'
switch (status) {
// A live agent with no transcript yet — and a loaded-but-empty transcript —
// are both "start a chat"; invite the first message instead of implying the
// agent is still starting up.
// A live agent with no transcript yet — an unwritten transcript file, or a
// loaded-but-empty one — is "start a chat"; invite the first message instead
// of implying the agent is still starting up.
case 'waiting-session':
case 'awaiting-transcript':
case 'ready':
return formatNativeChatEmptyStateCopy('empty', agentLabel)
case 'error': {
@@ -41,23 +45,28 @@ export type MobileNativeChatPendingItem = {
id: string
text: string
images?: string[]
/** Transcript tail when the send was issued. The echo renders directly after
* that row, so a send whose row never arrives stays where it was sent instead
* of trailing every turn that lands afterwards. */
baselineTailMessageId?: string | null
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
// Normalize first (desktop assembler parity): image marker turns fold into
// image-ref blocks instead of rendering as raw `[Image: …]` text.
return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
return stripNoiseMessages(foldToolMessages(normalizeImageTranscriptMessages(messages)))
}
/** Assemble the list data the chat renders: the folded transcript, then a
* synthetic bubble for the streaming text the gate let through, then the
* route-owned accepted optimistic messages at the tail. */
/** Assemble the folded transcript, streaming text, and optimistic user echoes. */
export function buildMobileNativeChatTransientData({
messages,
folded,
streaming,
pending,
imagePreviewsByMessageId
}: {
/** Raw transcript rows, used to project folded-away send boundaries. */
messages: NativeChatMessage[]
folded: NativeChatMessage[]
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
streaming: string | null
@@ -84,31 +93,101 @@ export function buildMobileNativeChatTransientData({
}
return { ...message, blocks }
})
const data: NativeChatMessage[] = [
...renderedFolded,
...(streaming
? [
{
id: 'streaming',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: streaming }],
timestamp: null,
source: 'hook' as const
}
]
: []),
...pending.map((p) => ({
id: p.id,
role: 'user' as const,
// Why anchored rather than appended: an echo whose transcript row never
// arrives — Claude consumes a mid-turn send without writing a user record —
// used to sit at the tail forever, re-reading below every turn that landed
// afterwards. That is what makes the conversation look re-ordered. Rendering it
// after the row it was sent against keeps it in place, so an unmatched echo is
// at worst a duplicate in the right position instead of a scrambled one.
const anchoredPending = new Map<string, NativeChatMessage[]>()
const leadingPending: NativeChatMessage[] = []
const trailingPending: NativeChatMessage[] = []
const foldedIds = new Set(renderedFolded.map((message) => message.id))
const missingBaselineIds = new Set<string>()
for (const item of pending) {
const baselineId = item.baselineTailMessageId
if (baselineId && !foldedIds.has(baselineId)) {
missingBaselineIds.add(baselineId)
}
}
const foldedAnchorByRawId = new Map<string, string>()
const leadingBaselineIds = new Set<string>()
if (missingBaselineIds.size > 0) {
let lastVisibleId: string | null = null
const forwardImageBaselineIds: string[] = []
for (const message of messages) {
if (foldedIds.has(message.id)) {
for (const baselineId of forwardImageBaselineIds) {
foldedAnchorByRawId.set(baselineId, message.id)
}
forwardImageBaselineIds.length = 0
lastVisibleId = message.id
}
if (!missingBaselineIds.has(message.id)) {
continue
}
if (isImageSourceUserTurn(message)) {
forwardImageBaselineIds.push(message.id)
} else if (lastVisibleId) {
foldedAnchorByRawId.set(message.id, lastVisibleId)
} else {
leadingBaselineIds.add(message.id)
}
}
}
for (const item of pending) {
const bubble: NativeChatMessage = {
id: item.id,
role: 'user',
// Text first (when present), then a thumbnail per ridden-along image so the
// sent photo shows immediately, before the transcript echo lands.
blocks: [
...(p.text ? [{ type: 'text' as const, text: p.text }] : []),
...(p.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri }))
...(item.text ? [{ type: 'text' as const, text: item.text }] : []),
...(item.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri }))
],
timestamp: null,
source: 'transcript' as const
}))
]
source: 'transcript'
}
// Tool/noise rows fold backward; image-source rows fold into their following prompt.
const baselineId = item.baselineTailMessageId
if (baselineId && leadingBaselineIds.has(baselineId)) {
leadingPending.push(bubble)
continue
}
const anchor = baselineId
? foldedIds.has(baselineId)
? baselineId
: foldedAnchorByRawId.get(baselineId)
: undefined
if (!anchor || !foldedIds.has(anchor)) {
trailingPending.push(bubble)
continue
}
const siblings = anchoredPending.get(anchor)
if (siblings) {
siblings.push(bubble)
} else {
anchoredPending.set(anchor, [bubble])
}
}
const data: NativeChatMessage[] = [...leadingPending]
for (const message of renderedFolded) {
data.push(message)
const attached = anchoredPending.get(message.id)
if (attached) {
data.push(...attached)
}
}
if (streaming) {
data.push({
id: 'streaming',
role: 'assistant',
blocks: [{ type: 'text', text: streaming }],
timestamp: null,
source: 'hook'
})
}
data.push(...trailingPending)
return { folded: renderedFolded, streaming, data }
}
@@ -176,7 +176,7 @@ describe('sendMobileNativeChatMessage', () => {
).resolves.toBe('rejected')
})
it('prepends the input-line clear byte when clearInputFirst is set', async () => {
it('never bundles terminal controls into the submitted body', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
@@ -187,14 +187,13 @@ describe('sendMobileNativeChatMessage', () => {
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
text: 'hello'
})
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: '\x15hello',
text: 'hello',
enter: true
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
@@ -214,8 +213,7 @@ describe('sendMobileNativeChatMessage', () => {
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'what is this',
clearInputFirst: false
text: 'what is this'
})
const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string }
expect(sent.text).toBe('what is this')
@@ -398,36 +396,3 @@ describe('clearMobileNativeChatInput', () => {
expect(client.sendRequest).not.toHaveBeenCalled()
})
})
describe('the body write never carries a multi-line burst', () => {
const accepted = {
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
const sentText = (client: RpcClient): string =>
(vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text
it('still prefixes only a single Ctrl+U when asked to clear first', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
})
expect(sentText(client)).toBe('\x15hello')
})
it('never prefixes a clear when the caller already pasted (image sends)', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'caption',
clearInputFirst: false
})
expect(sentText(client)).toBe('caption')
})
})
+1 -12
View File
@@ -9,22 +9,11 @@ type MobileTerminalClient = {
type: 'mobile'
}
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
// same byte before its body), so a launch-context prefill parked there cannot
// concatenate with a mobile chat message. The host writes text bytes verbatim.
//
// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked
// launch draft is routinely multi-line (every Linear block is); callers that know
// one is parked must call clearMobileNativeChatInput FIRST — see
// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law.
const CLEAR_UNSUBMITTED_INPUT = '\x15'
type MobileNativeChatSendArgs = {
client: RpcClient
terminal: string
text: string
enter?: boolean
clearInputFirst?: boolean
/** Exact host launch draft this submitting write resolves when accepted. */
resolvedLaunchDraft?: { text: string; createdAt: number }
mobileClient?: MobileTerminalClient
@@ -65,7 +54,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
'terminal.send',
{
terminal: args.terminal,
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
text: args.text,
enter: args.enter ?? true,
...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}),
...(args.mobileClient ? { client: args.mobileClient } : {})
@@ -37,6 +37,36 @@ describe('applyMobileNativeChatStreamFrame', () => {
})
})
it('marks a pending snapshot so the caller can settle the view but not the read', () => {
const merger = createNativeChatMerger()
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: [], hasMore: false, pending: true },
limit: 40,
replaceSnapshot: true
})
expect(result).toEqual({
kind: 'messages',
messages: [],
hasMore: false,
windowReplaced: true,
pending: true
})
})
it('leaves an ordinary snapshot unmarked', () => {
const merger = createNativeChatMerger()
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: [message('a')], hasMore: false },
limit: 40,
replaceSnapshot: true
})
expect(result).not.toHaveProperty('pending')
})
it('merges reconnect snapshots and live appends into the bounded window', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('a'), message('b')])
@@ -7,6 +7,10 @@ export type MobileNativeChatStreamFrame = {
messages?: NativeChatMessage[]
hasMore?: boolean
beforeOffset?: number
/** Snapshot only: no transcript file exists behind this window yet (the agent
* has not flushed, or was never prompted). The empty list is real enough to
* render, but it is not a settled read of the session's history. */
pending?: boolean
error?: string
message?: string
}
@@ -24,6 +28,9 @@ export type AppliedMobileNativeChatFrame =
* snapshot, or a replay snapshot disjoint from local history) — the
* caller must reset its paging window/cursor to the frame's. */
windowReplaced?: boolean
/** The transcript behind this window does not exist yet: show it, but keep
* the read open — the real snapshot follows on the same subscription. */
pending?: boolean
}
function replayRetainedTailStart(
@@ -80,6 +87,7 @@ export function applyMobileNativeChatStreamFrame(args: {
if (!Array.isArray(frame.messages)) {
return { kind: 'ignored' }
}
const pending = frame.type === 'snapshot' && frame.pending === true
const replayStartIndex =
frame.type === 'snapshot' && !replaceSnapshot && merger.list.length > 0
? replayRetainedTailStart(merger, frame.messages, frame.hasMore)
@@ -91,6 +99,7 @@ export function applyMobileNativeChatStreamFrame(args: {
messages: merger.list,
hasMore: frame.hasMore,
windowReplaced: true,
...(pending ? { pending: true } : {}),
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
}
}
@@ -101,6 +110,7 @@ export function applyMobileNativeChatStreamFrame(args: {
return {
kind: 'messages',
messages,
...(pending ? { pending: true } : {}),
// Why: once the bounded live window drops its oldest row, the snapshot's
// byte cursor no longer describes the oldest retained message.
...(cursorInvalidated ? { cursorInvalidated: true } : {}),
@@ -129,6 +129,12 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
beforeEach(() => {
vi.clearAllMocks()
clientStub.sendRequest.mockResolvedValue({
id: 'send',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'r' }
})
resetMobileNativeChatStaleInputForTests()
captureSendOrigin.mockReturnValue(ORIGIN)
act(() => {
@@ -157,12 +163,10 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
accepted = await controller!.handleNativeChatSend('answer')
})
expect(accepted).toBe(true)
expect(clientStub.sendRequest).toHaveBeenCalledTimes(1)
expect(clientStub.sendRequest.mock.calls[0]?.[1]).toMatchObject({
terminal: 'term-1',
text: '\x15',
enter: false
})
expect(clientStub.sendRequest).toHaveBeenCalledTimes(2)
for (const call of clientStub.sendRequest.mock.calls) {
expect(call[1]).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
}
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
})
@@ -228,7 +232,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('pre-clears the input line for a text-only send but never for an image send', async () => {
it('pre-clears separately for a text-only send but never for an image send', async () => {
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
// this send; a second clear here wipes the image off the input line and the
// agent receives text alone while the echo bubble still shows the thumbnail.
@@ -237,16 +241,23 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
await act(async () => {
await controller!.handleNativeChatSend('answer')
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'answer', clearInputFirst: true })
expect(clientStub.sendRequest).toHaveBeenCalledWith(
'terminal.send',
expect.objectContaining({ text: '\x15', enter: false }),
expect.any(Object)
)
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.not.objectContaining({ clearInputFirst: expect.anything() })
)
clientStub.sendRequest.mockClear()
await act(async () => {
await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'look', clearInputFirst: false })
)
expect(clientStub.sendRequest).not.toHaveBeenCalled()
expect(sendWithOutcome).toHaveBeenLastCalledWith(expect.objectContaining({ text: 'look' }))
expect(sendWithOutcome.mock.calls.at(-1)?.[0]).not.toHaveProperty('clearInputFirst')
})
it('holds an unknown-outcome send without posting the optimistic echo', async () => {
@@ -41,6 +41,7 @@ function renderedPendingTexts(
state: DraftState | null
): (string | undefined)[] {
const { data } = buildMobileNativeChatTransientData({
messages,
folded: messages,
streaming: null,
pending: state?.pending ?? []
@@ -77,12 +77,10 @@ describe('useMobileNativeChatMessageSend', () => {
const sentArgs = (): {
text?: string
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
} =>
sendWithOutcome.mock.calls[0]![0] as {
text?: string
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
}
const clearArgs = (): { clearInput?: string } =>
@@ -146,32 +144,36 @@ describe('useMobileNativeChatMessageSend', () => {
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => {
it('keeps the body write free of the dedicated terminal control', async () => {
// A Ctrl+U written immediately before body text in the SAME write arrives as
// a literal control character, so it would head the received message.
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs()).not.toHaveProperty('clearInputFirst')
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => {
it('writes a single Ctrl+U separately when no launch draft is parked', async () => {
mount(() => null)
await act(async () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(true)
expect(clearArgs().clearInput).toBe('\x15')
expect(sentArgs()).not.toHaveProperty('clearInputFirst')
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('writes no clear at all when nothing is parked on the line', async () => {
it('writes the ordinary clear before the body', async () => {
mount(() => null)
await act(async () => {
await api!.send('hello')
})
expect(clearInputWrite).not.toHaveBeenCalled()
expect(clearInputWrite).toHaveBeenCalledTimes(1)
expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan(
sendWithOutcome.mock.invocationCallOrder[0]!
)
})
it('reads the draft at send time, so a retired seed stops widening the clear', async () => {
@@ -184,9 +186,11 @@ describe('useMobileNativeChatMessageSend', () => {
await act(async () => {
await api!.send('second')
})
expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true })
expect(clearInputWrite).toHaveBeenCalledTimes(1)
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
expect(clearInputWrite).toHaveBeenCalledTimes(2)
expect(clearInputWrite.mock.calls[0]![0]).toMatchObject({
clearInput: buildAgentTuiClearInputForText(DRAFT)
})
expect(clearInputWrite.mock.calls[1]![0]).toMatchObject({ clearInput: '\x15' })
})
it('does not clear an image send after the image was pasted', async () => {
@@ -196,7 +200,7 @@ describe('useMobileNativeChatMessageSend', () => {
await api!.send('caption', ['file:///a.png'])
})
expect(clearInputWrite).not.toHaveBeenCalled()
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs()).not.toHaveProperty('clearInputFirst')
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
@@ -17,7 +17,10 @@ import {
} from './mobile-native-chat-terminal-write-lock'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import {
AGENT_TUI_CLEAR_INPUT_LINE,
buildAgentTuiClearInputForText
} from '../../../src/shared/agent-tui-input-clear'
export type MobileNativeChatMessageSend = {
/** Composer send that syncs the draft (clear on send, restore on rejection). */
@@ -131,23 +134,22 @@ export function useMobileNativeChatMessageSend(args: {
if (syncComposer) {
clearDraftForSend(origin, draftText)
}
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
// only one logical line. Size the clear to the text Orca injected, with
// slack — the user can also have typed into the TUI line directly, so that
// line count is a lower bound. Mobile cannot read the agent's screen, so
// there is no empty-line observable to confirm against here; the upper
// bound plus the host's write acceptance is what makes it safe.
//
// The burst goes out as its OWN write: bundled into the body write it
// arrived as literal Ctrl+U text and the draft concatenated (see
// clearMobileNativeChatInput). A rejected clear aborts the send rather
// than pasting on top of an uncleared line.
const seededLaunchDraft = readSeededLaunchDraftSeed()
if (seededLaunchDraft && !images?.length) {
const classification = classifyMobileNativeChatSend(agent, text)
const typesCodexCommand =
agent === 'codex' &&
classification !== 'chat' &&
isSlashCommandDraft(text) &&
!images?.length
// Keep terminal controls in their own write. When bundled with the body,
// a pasted burst can become literal prompt text instead of editing input.
if (!images?.length && (seededLaunchDraft || !typesCodexCommand)) {
const cleared = await clearMobileNativeChatInput({
client,
terminal: handle,
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text),
clearInput: seededLaunchDraft
? buildAgentTuiClearInputForText(seededLaunchDraft.text)
: AGENT_TUI_CLEAR_INPUT_LINE,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -161,7 +163,6 @@ export function useMobileNativeChatMessageSend(args: {
return 'rejected'
}
}
const classification = classifyMobileNativeChatSend(agent, text)
const mobileClient = deviceTokenRef.current
? { id: deviceTokenRef.current, type: 'mobile' as const }
: undefined
@@ -169,30 +170,23 @@ export function useMobileNativeChatMessageSend(args: {
syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt }
: undefined
const outcome =
agent === 'codex' &&
classification !== 'chat' &&
isSlashCommandDraft(text) &&
!images?.length
? await typeMobileNativeChatCommandWithOutcome({
client,
terminal: handle,
command: text,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
...(mobileClient ? { mobileClient } : {}),
deadline
})
: await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text,
// Why: an image send already cleared before its paste; a second
// clear here would wipe the image before submission.
clearInputFirst: !images?.length && !seededLaunchDraft,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
deadline,
...(mobileClient ? { mobileClient } : {})
})
const outcome = typesCodexCommand
? await typeMobileNativeChatCommandWithOutcome({
client,
terminal: handle,
command: text,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
...(mobileClient ? { mobileClient } : {}),
deadline
})
: await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text,
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
deadline,
...(mobileClient ? { mobileClient } : {})
})
// Why (desktop parity): a slash/skill send dispatches into the agent's own
// TUI, not the conversation — the transcript never echoes it as a user
// turn, so an optimistic bubble would never reconcile and the
@@ -0,0 +1,91 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FONT_SCALE_STEP, quantizeFontScale } from './mobile-native-chat-message-text'
const handlers: { start?: () => void; update?: (e: { scale: number }) => void } = {}
vi.mock('react-native-gesture-handler', () => {
const pinch = {
runOnJS: () => pinch,
onStart: (cb: () => void) => {
handlers.start = cb
return pinch
},
onUpdate: (cb: (e: { scale: number }) => void) => {
handlers.update = cb
return pinch
}
}
return {
Gesture: { Simultaneous: (...g: unknown[]) => g, Native: () => ({}), Pinch: () => pinch }
}
})
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
describe('quantizeFontScale', () => {
it('snaps to the commit step and stays inside the supported range', () => {
expect(quantizeFontScale(1)).toBe(1)
expect(quantizeFontScale(1.01)).toBe(1)
expect(quantizeFontScale(1.024)).toBe(1)
expect(quantizeFontScale(1.03)).toBe(1.05)
expect(quantizeFontScale(5)).toBe(1.8)
expect(quantizeFontScale(0.1)).toBe(0.8)
expect(quantizeFontScale(Number.NaN)).toBe(1)
})
it('collapses a full-range pinch to at most one commit per step', () => {
const committed = new Set<number>()
for (let i = 0; i <= 600; i++) {
committed.add(quantizeFontScale(0.7 + i * 0.002))
}
expect(committed.size).toBeLessThanOrEqual(Math.round(1 / FONT_SCALE_STEP) + 1)
})
})
describe('useMobileNativeChatPinchGesture', () => {
let renderer: { unmount: () => void } | null = null
let latest = 1
function Probe(): null {
latest = useMobileNativeChatPinchGesture().fontScale
return null
}
beforeEach(() => {
latest = 1
act(() => {
renderer = create(createElement(Probe))
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
// Why this matters: `renderItem` closes over `fontScale`, so each committed
// value re-measures every message in the list. Committing raw gesture scales
// drives hundreds of whole-list re-measures per pinch, and a stray second
// finger during an ordinary scroll starts that storm at scales the user cannot
// even see. Those re-measures are what expose the recycled-paragraph repaint
// that silently drops the tail of a long message.
it('commits nothing for gesture noise the user cannot see', () => {
act(() => handlers.start?.())
for (const scale of [1.001, 0.995, 1.008, 1.02, 0.982]) {
act(() => handlers.update?.({ scale }))
}
expect(latest).toBe(1)
})
it('commits a real pinch, on the step grid', () => {
act(() => handlers.start?.())
act(() => handlers.update?.({ scale: 1.34 }))
expect(latest).toBe(1.35)
act(() => handlers.update?.({ scale: 1.37 }))
expect(latest).toBe(1.35)
act(() => handlers.update?.({ scale: 1.42 }))
expect(latest).toBe(1.4)
})
})
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from 'react'
import { Gesture } from 'react-native-gesture-handler'
import type { ComposedGesture } from 'react-native-gesture-handler'
import { clampFontScale } from './mobile-native-chat-message-text'
import { quantizeFontScale } from './mobile-native-chat-message-text'
/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase`
* anchors the live gesture so successive pinches compound rather than reset. */
@@ -14,8 +14,8 @@ export function useMobileNativeChatPinchGesture(): {
fontScaleRef.current = fontScale
const pinchBase = useRef(1)
// Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so
// they can touch React refs/state and clampFontScale directly — accessing those
// from the UI-thread worklet crashes the app.
// they can touch React refs/state and quantizeFontScale directly — accessing
// those from the UI-thread worklet crashes the app.
// Compose the pinch with the list's native scroll as Simultaneous so a
// two-finger pinch is recognized even while the scroll view is active —
// otherwise the scroll grabs the gesture first and the pinch never fires.
@@ -29,7 +29,7 @@ export function useMobileNativeChatPinchGesture(): {
pinchBase.current = fontScaleRef.current
})
.onUpdate((e) => {
setFontScale(clampFontScale(pinchBase.current * e.scale))
setFontScale(quantizeFontScale(pinchBase.current * e.scale))
})
),
[]
@@ -633,4 +633,81 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
ids: []
})
})
it('settles the view but not the read on a pending snapshot', async () => {
// The host answers with an empty pending window while the transcript file
// does not exist yet. Calling that 'ready' would let the launch-draft seed
// adopt a prefill the agent may already have been sent; leaving it
// 'loading' is the bare forever-spinner this frame exists to end.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [], hasMore: false, pending: true })
return () => {}
})
await mountAt({ subscribe } as unknown as RpcClient, 'session-a')
expect(subscribe).toHaveBeenCalledWith(
'nativeChat.subscribe',
expect.objectContaining({ capabilities: { transcriptPending: 1 } }),
expect.any(Function)
)
expect(renders.at(-1)).toMatchObject({
status: 'awaiting-transcript',
transcriptLoading: true,
ids: []
})
})
it('takes the real snapshot after a pending one as this subscription’s base', async () => {
let emitFrame: (frame: unknown) => void = () => {}
const client = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
emitFrame = onData
onData({ type: 'snapshot', messages: [], hasMore: false, pending: true })
return () => {}
})
} as unknown as RpcClient
await mountAt(client, 'session-a')
await act(async () =>
emitFrame({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
)
expect(renders.at(-1)).toMatchObject({
status: 'ready',
transcriptLoading: false,
ids: ['a-1']
})
})
it('never captures a pending window over the retained transcript', async () => {
const client = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
})
} as unknown as RpcClient
await mountAt(client, 'session-a')
// Same identity, fresh client: this read finds no transcript file behind the
// session and answers pending.
const reconnected = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
onData({ type: 'snapshot', messages: [], hasMore: false, pending: true })
return () => {}
})
} as unknown as RpcClient
await act(async () =>
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
)
// One more rebind reads retention back: it still holds the real history,
// which it could not had the empty pending window captured over it.
const rebound = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client: rebound, sessionId: 'session-a' }))
)
expect(renders.at(-1)).toMatchObject({ status: 'loading', ids: ['a-1'] })
})
})
@@ -12,16 +12,25 @@ import {
type MobileNativeChatStreamFrame
} from './mobile-native-chat-stream-frame'
export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error'
export type MobileNativeChatStatus =
| 'idle'
| 'loading'
| 'waiting-session'
/** The host answered, but the session has no transcript file yet — render the
* empty chat instead of a spinner while the read stays open. */
| 'awaiting-transcript'
| 'ready'
| 'error'
export type MobileNativeChatSession = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
/** True while `messages` cannot be trusted as this session's real history:
* the read is in flight, OR the subscription effect has not yet caught up to
* a just-changed agent/session, so `messages`/`status` still describe the
* previous tab. Consumers that decide something from an empty transcript
* (the launch-draft seed) must wait for this to clear. */
* the read is in flight, the transcript has not been written yet, OR the
* subscription effect has not yet caught up to a just-changed agent/session,
* so `messages`/`status` still describe the previous tab. Consumers that
* decide something from an empty transcript (the launch-draft seed) must
* wait for this to clear. */
transcriptLoading: boolean
error?: string
/** True when an older page may exist (the last read filled the window). */
@@ -145,6 +154,7 @@ export function useMobileNativeChatSession(args: {
sessionId,
limit: limitRef.current,
subscriptionId: buildNativeChatSubscriptionId(agent, sessionId),
capabilities: { transcriptPending: 1 },
...(transcriptPath ? { transcriptPath } : {})
},
(raw) => {
@@ -166,7 +176,9 @@ export function useMobileNativeChatSession(args: {
setError(applied.error)
return
}
if (frame.type === 'snapshot') {
if (frame.type === 'snapshot' && !applied.pending) {
// A pending window has no transcript behind it, so the snapshot that
// follows is still this subscription's base, not a reconnect replay.
snapshotSeenRef.current = true
}
if (applied.windowReplaced || frame.type === 'snapshot') {
@@ -198,7 +210,7 @@ export function useMobileNativeChatSession(args: {
setLoadingEarlier(false)
beforeOffsetRef.current = null
}
setRead({ client, identity, status: 'ready' })
setRead({ client, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' })
}
)
@@ -286,7 +298,7 @@ export function useMobileNativeChatSession(args: {
// clears the previous tab's list is passive, so `messages` lags a commit.
messages: visibleMessages,
status,
transcriptLoading: status === 'loading',
transcriptLoading: status === 'loading' || status === 'awaiting-transcript',
error,
hasMore,
loadingEarlier,
+69 -2
View File
@@ -38,7 +38,10 @@ type FakeClient = RpcClient & {
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
function makeFakeClient(
initialState: ConnectionState,
activePath: MobileConnectionPath = 'tailscale'
): FakeClient {
let state = initialState
let pendingPath: MobileConnectionPath | null = null
let pairingRejected = false
@@ -52,7 +55,7 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
getActivePath: () => 'tailscale',
getActivePath: () => activePath,
getPendingPath: () => pendingPath,
isPairingRejected: () => pairingRejected,
onConnectionPathChange: (listener: () => void) => {
@@ -355,6 +358,70 @@ describe('useHostClient', () => {
}
})
it('nudges an existing Relay session instead of starting a fresh direct dial', async () => {
const relayClient = makeFakeClient('disconnected', 'relay')
connectMock.mockReturnValue(relayClient)
loadHostsMock.mockResolvedValue([HOST])
let forceReconnect: ((hostId: string) => Promise<void>) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
forceReconnect = useForceReconnect()
useHostClient(HOST.id)
return null
}
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
await act(async () => {
await forceReconnect?.(HOST.id)
})
expect(relayClient.closeMock).not.toHaveBeenCalled()
expect(relayClient.notifyForeground).toHaveBeenCalledWith('app-resume')
expect(connectMock).toHaveBeenCalledOnce()
} finally {
act(() => renderer?.unmount())
}
})
it('rebuilds a pairing-rejected Relay client so re-pairing credentials are re-read', async () => {
const rejectedRelayClient = makeFakeClient('disconnected', 'relay')
const replacement = makeFakeClient('connecting', 'tailscale')
connectMock.mockReturnValueOnce(rejectedRelayClient).mockReturnValueOnce(replacement)
loadHostsMock.mockResolvedValue([HOST])
let forceReconnect: ((hostId: string) => Promise<void>) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
forceReconnect = useForceReconnect()
useHostClient(HOST.id)
return null
}
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
act(() => rejectedRelayClient.emitPairingRejected(true))
await act(async () => {
await forceReconnect?.(HOST.id)
})
expect(rejectedRelayClient.closeMock).toHaveBeenCalled()
expect(rejectedRelayClient.notifyForeground).not.toHaveBeenCalled()
expect(connectMock).toHaveBeenCalledTimes(2)
} finally {
act(() => renderer?.unmount())
}
})
it('does not open a client after the host is closed during an in-flight lookup', async () => {
let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null
const hostLookup = new Promise<(typeof HOST)[]>((resolve) => {
+9
View File
@@ -11,6 +11,7 @@ import {
type ReactNode
} from 'react'
import type { RpcClient } from './rpc-client'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import {
@@ -19,6 +20,7 @@ import {
} from './host-client-acquisition-registry'
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
import { shouldPreserveActiveRelay } from './relay-reconnect-preservation'
import {
createHostClientSelectors,
listHostClients,
@@ -238,6 +240,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const forceReconnect = useCallback(
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
const logical = entry?.client as Partial<StableLogicalRpcClient> | undefined
if (entry && shouldPreserveActiveRelay(entry, logical)) {
// Keep a Relay-active host on its existing recovery state; rebuilding the
// facade starts the unreachable direct endpoint before Relay can race it.
entry.client.notifyForeground('app-resume')
return
}
// Why: ownership survives explicit close/re-pair while observers never become synthetic owners.
const savedRefCount = acquisitionsRef.current.count(hostId)
manualDemandRef.current.add(hostId)
@@ -127,6 +127,18 @@ describe('classifyConnection Tailscale hint', () => {
})
expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale')
})
it('does not call an idle Relay path Connecting when no retry is active', () => {
const verdict = classifyConnection({
state: 'disconnected',
reconnectAttempts: 0,
lastConnectedAt: 1_000,
pendingPath: 'relay',
nowMs: 1_000_000
})
expect(verdict).toEqual({ kind: 'normal', label: 'Disconnected' })
})
})
// Issue #10119: every redial re-enters 'connecting', which used to revert an
+4 -1
View File
@@ -70,7 +70,10 @@ export function classifyConnection(args: {
return { kind: 'normal', label: 'Connected' }
}
if (args.pendingPath === 'relay') {
// A disconnected pending path can survive a cleared retry timer during a
// lifecycle race. Only narrate Relay while dialing or after a retry has
// recorded progress; otherwise the idle transport must read Disconnected.
if (args.pendingPath === 'relay' && (state !== 'disconnected' || reconnectAttempts > 0)) {
if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) {
if (lastConnectedAt == null) {
return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'never-connected' }
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { selectConnectableHostProfiles } from './host-catalog-selection'
import { selectConnectableHostProfiles, sortHostsByLastConnected } from './host-catalog-selection'
import type { HostCatalogEntry, HostProfile } from './types'
const profile: HostProfile = {
@@ -36,4 +36,27 @@ describe('selectConnectableHostProfiles', () => {
])
).toEqual([profile])
})
it('sorts hosts by recency without copied-array methods or input mutation', () => {
const hosts = [
{ ...profile, id: 'oldest', lastConnected: 1 },
{ ...profile, id: 'newest', lastConnected: 3 },
{ ...profile, id: 'middle', lastConnected: 2 }
]
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toSorted')
Reflect.deleteProperty(Array.prototype, 'toSorted')
try {
expect(sortHostsByLastConnected(hosts).map((host) => host.id)).toEqual([
'newest',
'middle',
'oldest'
])
expect(hosts.map((host) => host.id)).toEqual(['oldest', 'newest', 'middle'])
} finally {
if (descriptor) {
Reflect.defineProperty(Array.prototype, 'toSorted', descriptor)
}
}
})
})
@@ -5,3 +5,9 @@ export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry
entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : []
)
}
export function sortHostsByLastConnected<T extends { lastConnected: number }>(
hosts: readonly T[]
): T[] {
return [...hosts].sort((left, right) => right.lastConnected - left.lastConnected)
}
@@ -89,6 +89,33 @@ describe('mobile endpoint supervisor nudges', () => {
supervisor.stop()
})
it('restarts a disconnected Relay immediately on an app-resume retry', async () => {
const logical = new FakeLogicalClient('disconnected', 'relay')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408)))
.mockReturnValueOnce(new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: vi.fn(() => new FakeSession('disconnected')),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
// Manual retry uses the same app-resume nudge as a background/foreground cycle.
supervisor.nudge('app-resume')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.getState()).toBe('connected')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('leaves physical relay probing to the session watchdog', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
@@ -212,4 +239,44 @@ describe('mobile endpoint supervisor nudges', () => {
expect(relaySession.close).toHaveBeenCalled()
expect(logical.getActivePath()).toBe('lan')
})
it('withdraws a relay replacement that authenticates after backgrounding', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const relaySession = new FakeRelaySession('connected')
const setActiveSession = vi.fn()
let active = true
logical.migrateTo.mockImplementation(async (_session, _path, _timeout, shouldAbort) => {
active = false
if (shouldAbort?.()) {
relaySession.close()
throw new Error('migration superseded')
}
})
const establisher = new MobileRelaySessionEstablisher({
logical,
controller: { setActiveSession } as unknown as RelayReconnectController,
openRelay: vi.fn(() => relaySession),
randomBytes: (length) => new Uint8Array(length),
writeBundle: vi.fn(async () => {}),
isActive: () => active,
isForeground: () => active,
relay: () => host.relay,
resolveRelay: vi.fn(async ({ relay: endpoint }) => endpoint),
persistResolvedRelay: vi.fn(async () => {}),
bundle: () => bundle,
adoptBundle: vi.fn(),
recordMigration: vi.fn(),
scheduleLease: vi.fn(),
scheduleDirectProbe: vi.fn(),
onBookkeepingError: vi.fn(),
onDialFailure: vi.fn()
})
const outcome = await establisher.dialEligible([bundle.current])
expect(outcome).toEqual({ outcome: 'aborted' })
expect(setActiveSession).not.toHaveBeenCalled()
expect(relaySession.close).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
})
})
@@ -16,6 +16,16 @@ export function suspendRelayIfStillConnected(
}
}
export function liveRelayLeaseExpiry(
logical: StableLogicalRpcClient,
stopped: boolean,
expiry: number | null
): number | null {
return !stopped && logical.getActivePath() === 'relay' && logical.getState() === 'connected'
? expiry
: null
}
type RelayDialResult = { ok: true } | { ok: false; error: Error }
// Why: a locally-aborted dial (background/stop/missing state) proves nothing about the
@@ -653,6 +653,10 @@ describe('mobile endpoint supervisor', () => {
await vi.waitFor(() => expect(deps.saveHost).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(30_000)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
supervisor.stop()
@@ -905,21 +909,4 @@ describe('mobile endpoint supervisor', () => {
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
supervisor.stop()
})
it('releases a background relay session and reconnects it on foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('disconnected')
expect(vi.getTimerCount()).toBe(0)
supervisor.setForeground(true)
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalled())
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
})
@@ -4,6 +4,7 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller'
import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import {
liveRelayLeaseExpiry,
persistRelayHost,
suspendRelayIfStillConnected
} from './mobile-endpoint-supervisor-support'
@@ -20,6 +21,7 @@ import { MobileRelaySessionEstablisher } from './mobile-relay-session-establishe
import * as recoveryPresentation from './mobile-relay-recovery-presentation'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ForegroundNudgeReason, HostProfile } from './types'
import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace'
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
@@ -30,7 +32,6 @@ const FAILURE_COOLDOWN_MS = 60_000
export class MobileEndpointSupervisor {
private bundle: MobileRelayCredentialBundle | null = null
private stopped = false
private foreground = true
private operationInFlight = false
private pendingReplace = false
private readonly nudgeRouter: MobileEndpointNudgeRouter
@@ -43,6 +44,7 @@ export class MobileEndpointSupervisor {
private readonly logRelay: RelayRecoveryLog
private readonly directProbe: DirectReturnProbe
private readonly directGrace: MobileRelayDirectGraceTimer
private readonly backgroundGrace: MobileRelayBackgroundGrace
private readonly sessionEstablisher: MobileRelaySessionEstablisher
constructor(
@@ -63,7 +65,7 @@ export class MobileEndpointSupervisor {
logical,
controller: this.relayReconnect,
isStopped: () => this.stopped,
isForeground: () => this.foreground,
isForeground: () => this.backgroundGrace.isForeground(),
setForeground: (foreground) => this.setForeground(foreground),
replaceRelay: () => void this.recoverRelay(true, true),
scheduleDirectProbe: () => this.directProbe.schedule(0)
@@ -84,24 +86,24 @@ export class MobileEndpointSupervisor {
openRelay: dependencies.openRelay,
randomBytes: dependencies.randomBytes,
writeBundle: dependencies.writeBundle,
isActive: () => !this.stopped && this.foreground,
isForeground: () => this.foreground,
isActive: () => this.isActive(),
isForeground: () => this.backgroundGrace.isForeground(),
relay: () => this.host.relay,
resolveRelay: dependencies.resolveRelay,
persistResolvedRelay: async (resolved) => {
this.host = await persistRelayHost(this.host, resolved, dependencies.saveHost)
},
bundle: () => this.bundle,
adoptBundle: (bundle) => {
this.bundle = bundle
},
adoptBundle: (bundle) => (this.bundle = bundle),
recordMigration: () => {
this.relayRotationPending = false
this.hysteresis.recordMigration(dependencies.now())
this.logRelay('runtime channel migrated to relay')
},
scheduleLease: (expiry) =>
this.leaseRotation.scheduleFromLease(this.stopped || !this.foreground ? null : expiry),
this.leaseRotation.scheduleFromLease(
liveRelayLeaseExpiry(this.logical, this.stopped, expiry)
),
scheduleDirectProbe: () => this.directProbe.schedule(),
onBookkeepingError: (error) =>
this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)),
@@ -111,12 +113,9 @@ export class MobileEndpointSupervisor {
this.directProbe = new DirectReturnProbe(dependencies, {
hysteresis: this.hysteresis,
host: () => this.host,
canSchedule: () =>
!this.stopped && this.foreground && this.logical.getActivePath() === 'relay',
canAttempt: () => !this.stopped && this.foreground && !this.operationInFlight,
beginOperation: () => {
this.operationInFlight = true
},
canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay',
canAttempt: () => this.isActive() && !this.operationInFlight,
beginOperation: () => (this.operationInFlight = true),
migrate: (client, path) => this.logical.migrateTo(client, path),
onDirectMigrated: async () => {
this.leaseRotation.clear()
@@ -134,6 +133,14 @@ export class MobileEndpointSupervisor {
}
}
})
this.backgroundGrace = new MobileRelayBackgroundGrace(
dependencies,
logical,
this.relayReconnect,
this.leaseRotation,
this.directProbe,
this.directGrace
)
}
async start(): Promise<void> {
@@ -153,6 +160,8 @@ export class MobileEndpointSupervisor {
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
}
this.directProbe.schedule()
} else if (!this.backgroundGrace.isForeground()) {
this.backgroundGrace.handleStateFailure()
} else {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
@@ -171,16 +180,9 @@ export class MobileEndpointSupervisor {
}
setForeground(foreground: boolean): void {
const wasForeground = this.foreground
this.foreground = foreground
if (foreground) {
this.relayReconnect.handleForeground(this.logical, wasForeground)
this.directProbe.schedule(0)
this.directGrace.arm()
} else {
// Why: background phones must not hold billed relay data splices.
this.relayReconnect.suspendActiveRelay(this.logical)
this.clearScheduledRecovery()
this.backgroundGrace.setForeground(foreground)
if (foreground && this.relayRotationPending) {
void this.recoverRelay(true)
}
}
@@ -190,15 +192,11 @@ export class MobileEndpointSupervisor {
this.stopped = true
this.unsubscribeState?.()
this.unsubscribeState = null
this.clearScheduledRecovery()
this.backgroundGrace.stop()
}
private clearScheduledRecovery(): void {
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
private isActive(): boolean {
return !this.stopped && this.backgroundGrace.isForeground()
}
// forceReplacement: dial past the "direct still looks live" guard — a lease
@@ -207,7 +205,7 @@ export class MobileEndpointSupervisor {
// shared cooldown and any session left stale-'connected' by a half-open socket
// comes down; lease rotation clears it because armRetry owns its own retry.
private async recoverRelay(forceReplacement = false, ownsRecovery = false): Promise<void> {
if (this.stopped || !this.foreground || !this.host.relay) {
if (!this.isActive() || !this.host.relay) {
return
}
if (this.operationInFlight) {
@@ -266,7 +264,7 @@ export class MobileEndpointSupervisor {
}
const recoveryNeeded =
forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState())
if (this.stopped || !this.foreground || !recoveryNeeded) {
if (!this.isActive() || !recoveryNeeded) {
return
}
this.logical.setRecoveryPath('relay', this.relayReconnect.getFailureCount())
@@ -285,7 +283,7 @@ export class MobileEndpointSupervisor {
}
// Why: cleanup may happen while a relay dial is awaiting the network;
// record its outcome without recreating a foreground retry timer.
const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped
const scheduleRetry = (!forceReplacement || ownsRecovery) && this.isActive()
this.relayReconnect.registerFailure(dialed.error, scheduleRetry)
recoveryPresentation.clearIfCredentialBlocked(this.logical, this.relayReconnect)
if (ownsRecovery) {
@@ -293,11 +291,11 @@ export class MobileEndpointSupervisor {
}
} finally {
this.operationInFlight = false
if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) {
if (forceReplacement && this.relayRotationPending && this.isActive()) {
this.leaseRotation.armRetry(this.relayReconnect.retryDelayMs(5000))
}
// Why: the active relay can drop while migration follow-up still owns the mutex.
if (retryAfterOperation && !this.stopped && this.foreground) {
if (retryAfterOperation && this.isActive()) {
void this.recoverRelay()
}
}
@@ -336,8 +334,7 @@ export class MobileEndpointSupervisor {
this.credentialRotationInFlight = false
if (
credentialRefreshed &&
!this.stopped &&
this.foreground &&
this.isActive() &&
this.relayReconnect.needsRecovery(this.logical.getState())
) {
void this.recoverRelay()
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
MobileRelayBackgroundGraceTimer,
RELAY_BACKGROUND_GRACE_MS
} from './mobile-relay-background-grace'
describe('MobileRelayBackgroundGraceTimer', () => {
afterEach(() => vi.useRealTimers())
it('expires once after the 30 second grace', async () => {
vi.useFakeTimers()
const onExpired = vi.fn()
const timer = new MobileRelayBackgroundGraceTimer(
{ now: Date.now, setTimer: setTimeout, clearTimer: clearTimeout },
onExpired
)
timer.arm()
await vi.advanceTimersByTimeAsync(RELAY_BACKGROUND_GRACE_MS - 1)
expect(onExpired).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(onExpired).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(RELAY_BACKGROUND_GRACE_MS)
expect(onExpired).toHaveBeenCalledOnce()
})
it('detects expiry on resume when the background timer was suspended', () => {
let now = 1_000
const timer = new MobileRelayBackgroundGraceTimer(
{
now: () => now,
setTimer: vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
clearTimer: vi.fn()
},
vi.fn()
)
timer.arm()
now += RELAY_BACKGROUND_GRACE_MS
expect(timer.consumeExpired()).toBe(true)
expect(timer.consumeExpired()).toBe(false)
})
it('rejects a stale expiry callback after foreground cancellation', () => {
let callback: (() => void) | null = null
const onExpired = vi.fn()
const timer = new MobileRelayBackgroundGraceTimer(
{
now: () => 1_000,
setTimer: vi.fn((next) => {
callback = next
return 1 as unknown as ReturnType<typeof setTimeout>
}),
clearTimer: vi.fn()
},
onExpired
)
timer.arm()
expect(timer.consumeExpired()).toBe(false)
const staleCallback = callback as (() => void) | null
staleCallback?.()
expect(onExpired).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,139 @@
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
// Retain a healthy Relay briefly across routine app switches without waking the app.
export const RELAY_BACKGROUND_GRACE_MS = 30_000
type RelayBackgroundGraceDependencies = {
now: () => number
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
export class MobileRelayBackgroundGraceTimer {
private deadlineAt: number | null = null
private generation = 0
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly dependencies: RelayBackgroundGraceDependencies,
private readonly onExpired: () => void
) {}
arm(): void {
this.clear()
this.deadlineAt = this.dependencies.now() + RELAY_BACKGROUND_GRACE_MS
const generation = this.generation
this.timer = this.dependencies.setTimer(() => {
if (generation !== this.generation || this.deadlineAt === null) {
return
}
this.timer = null
this.deadlineAt = null
this.onExpired()
}, RELAY_BACKGROUND_GRACE_MS)
}
consumeExpired(): boolean {
const expired = this.deadlineAt !== null && this.dependencies.now() >= this.deadlineAt
this.clear()
return expired
}
clear(): void {
this.generation += 1
this.deadlineAt = null
if (this.timer !== null) {
this.dependencies.clearTimer(this.timer)
this.timer = null
}
}
}
type Clearable = { clear(): void }
type DirectProbe = Clearable & { schedule(delayMs?: number): void }
type DirectGrace = Clearable & { arm(): void }
export class MobileRelayBackgroundGrace {
private foregroundState = true
private retainedRelaySuspended = false
private readonly timer: MobileRelayBackgroundGraceTimer
constructor(
dependencies: RelayBackgroundGraceDependencies,
private readonly logical: StableLogicalRpcClient,
private readonly relayReconnect: RelayReconnectController,
private readonly leaseRotation: Clearable,
private readonly directProbe: DirectProbe,
private readonly directGrace: DirectGrace
) {
this.timer = new MobileRelayBackgroundGraceTimer(dependencies, () => this.suspendRelay())
}
isForeground(): boolean {
return this.foregroundState
}
setForeground(foreground: boolean): void {
const wasForeground = this.foregroundState
this.foregroundState = foreground
if (foreground) {
this.foreground()
this.relayReconnect.handleForeground(this.logical, wasForeground)
this.directProbe.schedule(0)
this.directGrace.arm()
} else if (wasForeground) {
this.background()
}
}
stop(): void {
this.timer.clear()
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
}
handleStateFailure(): void {
this.timer.clear()
if (this.logical.getActivePath() === 'relay') {
this.suspendRelay()
}
}
private background(): void {
this.retainedRelaySuspended = false
const retainsRelay =
this.logical.getActivePath() === 'relay' && this.logical.getState() === 'connected'
this.directProbe.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
if (retainsRelay) {
this.timer.arm()
return
}
this.relayReconnect.clear()
this.leaseRotation.clear()
if (this.logical.getActivePath() === 'relay') {
this.suspendRelay()
}
}
private foreground(): void {
if (this.timer.consumeExpired()) {
this.suspendRelay()
}
}
private suspendRelay(): void {
if (this.retainedRelaySuspended || this.logical.getActivePath() !== 'relay') {
return
}
this.retainedRelaySuspended = true
this.timer.clear()
this.leaseRotation.clear()
this.relayReconnect.suspendActiveRelay(this.logical)
}
}
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
describe('mobile Relay background lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => vi.useRealTimers())
it('retains a relay session across a quick background and foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(29_999)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connected')
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(1)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('releases a retained relay after 30 seconds and recovers only on foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(29_999)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('disconnected')
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalledOnce())
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('enforces an overdue grace on foreground when the background timer was suspended', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
vi.setSystemTime(Date.now() + 30_000)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
supervisor.setForeground(true)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
await vi.waitFor(() => expect(deps.openRelay).toHaveBeenCalledOnce())
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('recovers after a retained relay fails while backgrounded', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(5_000)
logical.publishState('disconnected')
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.waitFor(() => expect(deps.openRelay).toHaveBeenCalledOnce())
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('does not retain or suspend a healthy direct session in the background', async () => {
const logical = new FakeLogicalClient('connected', 'tailscale')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(30_000)
expect(logical.suspendActiveSession).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connected')
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.stop()
})
it('resumes a lease rotation that came due during the background grace', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected', null, Date.now() + 90_000))
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(40_000)
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(20_000)
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('connected')
supervisor.setForeground(true)
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
it('arms lease rotation when confirmation persistence finishes during the grace', async () => {
let finishWrite: (() => void) | null = null
const writeStarted = new Promise<void>((resolve) => {
finishWrite = resolve
})
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected', null, Date.now() + 90_000))
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay,
writeBundle: vi.fn(() => writeStarted)
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const starting = supervisor.start()
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
supervisor.setForeground(false)
finishWrite?.()
await starting
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(60_000)
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
})
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { classifyConnection, type ConnectionVerdict } from './connection-health'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import {
bundle,
dependencies,
FakeRelaySession,
FakeSession,
@@ -56,9 +57,11 @@ describe('continuous Relay pairing-rejection escalation', () => {
return session
})
openRelay.mockImplementationOnce(() => activeRelay)
const readBundle = vi.fn(async () => bundle)
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay,
readBundle,
randomBytes: () => new Uint8Array([0, 0]),
onLog: () => {}
})
@@ -128,6 +131,27 @@ describe('continuous Relay pairing-rejection escalation', () => {
logical.close()
})
it('recovers a latched rejection through the manual app-resume nudge', async () => {
const { activeRelay, logical, openRelay, supervisor } = harness(
new MobileE2EEAuthenticationError()
)
await supervisor.start()
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(FOUR_HOURS_MS)
expect(logical.isPairingRejected()).toBe(true)
openRelay.mockImplementation(() => new FakeRelaySession('connected'))
supervisor.nudge('app-resume')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay.mock.calls.length).toBeGreaterThan(3)
expect(logical.getState()).toBe('connected')
expect(logical.isPairingRejected()).toBe(false)
supervisor.stop()
logical.close()
})
it('never latches when the desktop accepts before the budget is spent', async () => {
const { activeRelay, logical, supervisor } = harness(new MobileE2EEAuthenticationError(), 2)
@@ -3,6 +3,7 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
import { RelayDirectorHttpError } from './mobile-relay-resume-director'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
@@ -336,6 +337,41 @@ describe('relay reconnect controller', () => {
expect(onRetry).toHaveBeenCalledTimes(2)
})
it('paces the next dial by the director Retry-After instead of the local backoff', () => {
const onRetry = vi.fn()
const reconnect = createController(onRetry)
reconnect.registerFailure(new RelayDirectorHttpError(503, 30_000))
expect(reconnect.retryDelayMs(0)).toBe(30_000)
vi.advanceTimersByTime(29_999)
expect(onRetry).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onRetry).toHaveBeenCalledOnce()
})
it('keeps the local backoff when the director sent no Retry-After', () => {
const onRetry = vi.fn()
const reconnect = createController(onRetry)
reconnect.registerFailure(new RelayDirectorHttpError(500, null))
expect(reconnect.retryDelayMs(0)).toBe(250)
vi.advanceTimersByTime(250)
expect(onRetry).toHaveBeenCalledOnce()
})
it('never lets a short Retry-After shorten an escalated backoff', () => {
const reconnect = createController(vi.fn())
for (let failure = 0; failure < 6; failure++) {
reconnect.registerFailure(new RelayOuterError(4429), false)
}
reconnect.registerFailure(new RelayDirectorHttpError(503, 100), false)
expect(reconnect.retryDelayMs(0)).toBe(15_000)
})
it('uses grace only when the outer relay credential was rejected', () => {
const reconnect = createController(vi.fn())
@@ -7,6 +7,7 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays'
import { relayDirectorRetryAfterMs } from './mobile-relay-resume-director'
import { RelayCredentialEligibility } from './relay-credential-eligibility'
import { RelayPairingRejectionLatch } from './relay-pairing-rejection-latch'
import { RelayRecoveryFailureCount } from './relay-recovery-failure-count'
@@ -80,6 +81,11 @@ export class RelayReconnectController {
if (this.recoveryGate === 'external-signal') {
this.liftGate()
}
// Manual app-resume retries bypass transport cooldown, but never a fresh-credential gate.
const disconnectedOnResume = reason === 'app-resume' && logical.getState() === 'disconnected'
if (disconnectedOnResume && this.recoveryGate !== 'fresh-credential') {
this.reset()
}
if (
this.recoveryGate !== 'fresh-credential' &&
logical.getActivePath() === 'relay' &&
@@ -241,10 +247,9 @@ export class RelayReconnectController {
// replacement dial is not revocation — banking it would fire a false re-pair alarm
// the moment that healthy session drops for an unrelated transport error.
this.pairingRejection.record(this.activeSession ? null : error)
const code = error instanceof RelayOuterError ? error.code : null
const recovery =
code != null && isMobileRelayCloseCode(code)
? mobileRelayRecoveryFor(code, 'phone-resume')
error instanceof RelayOuterError && isMobileRelayCloseCode(error.code)
? mobileRelayRecoveryFor(error.code, 'phone-resume')
: null
if (
this.recoveryGate === 'fresh-credential' ||
@@ -259,10 +264,12 @@ export class RelayReconnectController {
const failureCount = this.failureCount.recordAfterConnection(this.activeRelayConnectedAt, now)
// Why: only a stable authenticated Relay resets the failure streak.
this.activeRelayConnectedAt = null
// Why: a director Retry-After is the server pacing a bounded overload, and it
// only reaches this branch — a director HTTP error carries no relay close code.
const delay =
recovery?.kind === 'retry-after-host-offline'
? this.delays.hostOfflineDelayMs()
: this.delays.transportDelayMs(failureCount)
: this.delays.transportDelayMs(failureCount, relayDirectorRetryAfterMs(error))
this.nextAttemptAt = now + delay
if (error instanceof MobileE2EEAuthenticationError) {
// Why: an E2EE rejection is usually pairing revocation, but it also fires
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import { RelayDirectorHttpError, resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
const relay = {
v: 1 as const,
@@ -63,4 +63,40 @@ describe('mobile relay resume director', () => {
resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized })
).rejects.toThrow(/too large/)
})
it('carries the bounded-overload Retry-After off a rejected resolve', async () => {
const resolveWith = (headers: Record<string, string>) =>
resolveMobileRelayEndpoint({
relay,
resumeToken: 'A'.repeat(43),
fetchImpl: vi.fn(async () => new Response(null, { status: 503, headers }))
})
await expect(resolveWith({ 'retry-after': '30' })).rejects.toMatchObject({
status: 503,
retryAfterMs: 30_000,
message: 'relay director resolve failed (503)'
})
// An HTTP-date form resolves against the clock, and both forms clamp.
const httpDate = new Date(Date.now() + 10 * 60_000).toUTCString()
await expect(resolveWith({ 'retry-after': httpDate })).rejects.toMatchObject({
retryAfterMs: 120_000
})
await expect(resolveWith({ 'retry-after': '999999' })).rejects.toMatchObject({
retryAfterMs: 120_000
})
// A past date or unparseable header leaves the local backoff in charge.
const pastDate = new Date(Date.now() - 10 * 60_000).toUTCString()
await expect(resolveWith({ 'retry-after': pastDate })).rejects.toMatchObject({
retryAfterMs: null
})
await expect(resolveWith({ 'retry-after': 'soon-ish' })).rejects.toMatchObject({
retryAfterMs: null
})
await expect(resolveWith({})).rejects.toMatchObject({
status: 503,
retryAfterMs: null
})
await expect(resolveWith({})).rejects.toBeInstanceOf(RelayDirectorHttpError)
})
})
@@ -1,7 +1,28 @@
import { z } from 'zod'
import { parseRelayRetryAfterMs } from '../../../src/shared/relay-retry-after-header'
import { MOBILE_RELAY_RETRY_AFTER_MAX_MS } from './mobile-relay-retry-delays'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
const MAX_RESPONSE_BYTES = 16 * 1024
// Carries the director's own pacing so the reconnect cooldown can honor a
// bounded-overload 503 instead of re-dialing on the local backoff.
export class RelayDirectorHttpError extends Error {
readonly name = 'RelayDirectorHttpError'
constructor(
readonly status: number,
readonly retryAfterMs: number | null
) {
super(`relay director resolve failed (${status})`)
}
}
// 0 when the director asked for nothing, so it only ever floors a local backoff.
export function relayDirectorRetryAfterMs(error: Error | null): number {
return error instanceof RelayDirectorHttpError ? (error.retryAfterMs ?? 0) : 0
}
const ResolveResponseSchema = z
.object({
v: z.literal(1),
@@ -32,7 +53,10 @@ export async function resolveMobileRelayEndpoint(args: {
signal: controller.signal
})
if (!response.ok) {
throw new Error(`relay director resolve failed (${response.status})`)
throw new RelayDirectorHttpError(
response.status,
parseRelayRetryAfterMs(response.headers.get('retry-after'), MOBILE_RELAY_RETRY_AFTER_MAX_MS)
)
}
const declaredLength = Number(response.headers.get('content-length') ?? 0)
if (declaredLength > MAX_RESPONSE_BYTES) {
@@ -12,17 +12,21 @@ const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000
// forever one-minute beacon and a fleet-wide gating event cannot phase-align.
const RELAY_GATE_REPROBE_BASE_MS = 60_000
const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000
// A director Retry-After only ever floors a delay, so cap what one header can
// park the phone for; anything longer is the gated reprobe cadence's job.
export const MOBILE_RELAY_RETRY_AFTER_MAX_MS = 120_000
// Every relay retry delay in one place: transport backoff, known-offline
// polling, and the escalating gated reprobe cadence, all jittered.
export class RelayRetryDelays {
constructor(private readonly randomBytes: (length: number) => Uint8Array) {}
transportDelayMs(consecutiveFailures: number): number {
// floorMs: a server-supplied Retry-After, which paces the dial out but never in.
transportDelayMs(consecutiveFailures: number, floorMs = 0): number {
const exponent = Math.max(0, consecutiveFailures - 1)
const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent)
// Full jitter (uniform in [0, cap)), floored so retries never busy-loop.
return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction()))
return Math.max(RELAY_BACKOFF_MIN_MS, floorMs, Math.floor(cap * this.jitterFraction()))
}
hostOfflineDelayMs(): number {
@@ -430,6 +430,49 @@ describe('relay runtime recovery without direct connectivity', () => {
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('restarts Relay promptly after the background grace expires', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies({ openDirect: vi.fn(() => new FakeSession('disconnected')) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(false)
expect(logical.getState()).toBe('connected')
await vi.advanceTimersByTimeAsync(30_000)
expect(logical.getState()).toBe('disconnected')
expect(logical.getPendingPath()).toBeNull()
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('connected')
expect(logical.getPendingPath()).toBeNull()
supervisor.stop()
})
it('recovers an expired background Relay through the app-resume manual retry nudge', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies({ openDirect: vi.fn(() => new FakeSession('disconnected')) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(30_000)
expect(logical.getState()).toBe('disconnected')
supervisor.nudge('app-resume')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
expect(logical.getState()).toBe('connected')
expect(logical.getPendingPath()).toBeNull()
supervisor.stop()
})
})
// The field failure's first symptom: a real direct rpc-client dialing an
@@ -39,7 +39,7 @@ export class MobileRelaySessionEstablisher {
adoptBundle: (bundle: MobileRelayCredentialBundle) => void
// Hysteresis stamp + rotation-pending clear + recovery log line.
recordMigration: () => void
// Owns the stopped/background null-out so a late resolve never re-arms a stale timer.
// Owns the stopped/disconnected guard so late bookkeeping cannot arm a stale timer.
scheduleLease: (expiry: number | null) => void
scheduleDirectProbe: () => void
onBookkeepingError: (error: Error) => void
@@ -103,11 +103,15 @@ export class MobileRelaySessionEstablisher {
`confirm-${encodeBase64Url(args.randomBytes(16))}`
)
try {
// Why: if an authenticated non-relay session appears while this dial is in
// flight (the grace race), withdraw instead of cutting over the winner.
await args.logical.migrateTo(session, 'relay', undefined, () => directWon(args.logical))
// Why: backgrounding or a direct winner withdraws this dial before cutover.
await args.logical.migrateTo(
session,
'relay',
undefined,
() => !args.isActive() || directWon(args.logical)
)
} catch (error) {
if (directWon(args.logical)) {
if (!args.isActive() || directWon(args.logical)) {
return { ok: false, error: new RelayDialAbortedError() }
}
return { ok: false, error: session.getFailure() ?? toError(error) }
@@ -1,7 +1,9 @@
import type { PairingCandidateClient } from './mobile-relay-physical-client'
export type PairingCandidatePath = 'direct' | 'relay'
export type PairingCandidate = {
path: 'direct' | 'relay'
path: PairingCandidatePath
client: PairingCandidateClient
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { attributePairingLogPath } from './pairing-log-path'
import type { ConnectionLogEntry } from './types'
const entry: ConnectionLogEntry = {
id: 'log-1',
ts: 1,
level: 'warn',
message: 'Reconnecting (attempt 2)',
detail: '10.5.0.2:6768'
}
describe('attributePairingLogPath', () => {
it('prefixes unlabelled lines with their pairing path', () => {
const entries: ConnectionLogEntry[] = []
attributePairingLogPath('direct', (value) => entries.push(value))!(entry)
expect(entries).toEqual([{ ...entry, message: 'Direct: Reconnecting (attempt 2)' }])
})
it('leaves a line that already names its path unchanged', () => {
const entries: ConnectionLogEntry[] = []
const relayEntry = { ...entry, message: 'Relay: cell dial failed' }
attributePairingLogPath('relay', (value) => entries.push(value))!(relayEntry)
expect(entries).toEqual([relayEntry])
})
it('labels relay lines that forgot their own prefix', () => {
const entries: ConnectionLogEntry[] = []
attributePairingLogPath('relay', (value) => entries.push(value))!(entry)
expect(entries[0]!.message).toBe('Relay: Reconnecting (attempt 2)')
})
it('stays undefined when there is no sink to attribute to', () => {
expect(attributePairingLogPath('direct', undefined)).toBeUndefined()
})
})
+27
View File
@@ -0,0 +1,27 @@
import type { PairingCandidatePath } from './pairing-candidate-race'
import type { ConnectionLogSink } from './types'
const PATH_PREFIX: Record<PairingCandidatePath, string> = {
direct: 'Direct: ',
relay: 'Relay: '
}
// Why: the direct and relay candidates race into one pairing log, and only the
// relay call sites prefixed themselves — so unlabelled direct lines
// ("Reconnecting (attempt 2)", detail 10.5.0.2:6768) read as the relay
// retrying a LAN address. Attributing at the candidate seam covers every line
// each path emits, including ones whose call site forgets to say which it is.
export function attributePairingLogPath(
path: PairingCandidatePath,
onLog: ConnectionLogSink | undefined
): ConnectionLogSink | undefined {
if (!onLog) {
return undefined
}
const prefix = PATH_PREFIX[path]
return (entry) => {
onLog(
entry.message.startsWith(prefix) ? entry : { ...entry, message: `${prefix}${entry.message}` }
)
}
}
@@ -164,8 +164,11 @@ describe('recovering pairing relay candidate', () => {
)
})
it('does not retry a same-epoch move to a different cell', async () => {
it('retries the stored assignment when the director reports a non-newer different cell', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const target = client(Promise.resolve(success()))
const persistMove = vi.fn()
const sleep = vi.fn(async () => {})
const resolveDirector = vi.fn(async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: 'https://relay-c2.onorca.dev',
@@ -175,6 +178,37 @@ describe('recovering pairing relay candidate', () => {
})
})
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => {
expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl)
return connects++ === 0 ? stale : target
},
resolveDirector,
persistMove,
now: () => 1,
random: () => 0,
sleep
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(resolveDirector).toHaveBeenCalledOnce()
// Why: the non-newer move must never be adopted — the stored cell is redialed.
expect(persistMove).not.toHaveBeenCalled()
expect(sleep).toHaveBeenCalledWith(250)
})
it('gives up with the dial error once same-assignment retries exhaust the budget', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const resolveDirector = vi.fn(async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: relay.assignmentEpoch - 1,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
})
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => {
@@ -188,8 +222,30 @@ describe('recovering pairing relay candidate', () => {
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/)
await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(1006))
expect(resolveDirector).toHaveBeenCalledTimes(3)
expect(connects).toBe(4)
})
it('keeps a cell load refusal out of director recovery', async () => {
const limited = client(Promise.reject(new RelayOuterError(4429)))
const resolveDirector = vi.fn()
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => {
connects++
return limited
},
resolveDirector,
persistMove: vi.fn(),
now: () => 1
})
// Why: 4429 is rejected after the cell reserves the invite credential, so a
// retry burns an attempt — and a director hop cannot relieve cell load.
await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(4429))
expect(resolveDirector).not.toHaveBeenCalled()
expect(connects).toBe(1)
})
@@ -49,9 +49,9 @@ export function createRecoveringPairingRelayCandidate(args: {
const random = args.random ?? Math.random
const sleep =
args.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)))
const backOff = async (attempt: number): Promise<void> => {
const backOff = async (attempt: number, floorMs = 0): Promise<void> => {
const capMs = Math.min(2_000, 100 * 2 ** attempt)
const delayMs = Math.floor(random() * (capMs + 1))
const delayMs = Math.max(floorMs, Math.floor(random() * (capMs + 1)))
if (delayMs > 0) {
log('info', `Relay: backing off ${delayMs}ms`)
}
@@ -89,14 +89,20 @@ export function createRecoveringPairingRelayCandidate(args: {
return await client.sendRequest(method, params)
} catch (error) {
lastError = error
if (isCurrentAssignmentMove(error, relay)) {
if (error instanceof RelayDirectorMoveNotNewerError) {
// Why: the director has no "assignment unchanged" verb — a non-newer
// move IS that answer. The stored assignment stays authoritative (the
// move is never adopted or persisted), so the only correct action is
// to re-dial it with a real pause, not to abandon the relay path.
log(
'info',
'Relay: director kept current assignment',
directorEchoedCurrentAssignment(error, relay)
? 'Relay: director kept current assignment'
: 'Relay: director has no newer assignment',
redactSocketEndpoint(relay.cellUrl)
)
client.close()
await backOff(attempt)
await backOff(attempt, NOT_NEWER_RETRY_FLOOR_MS)
if (closed) {
throw new Error('relay pairing client closed')
}
@@ -130,7 +136,11 @@ export function createRecoveringPairingRelayCandidate(args: {
}
}
function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean {
// Redialing the assignment the director just confirmed needs a real pause, not
// the near-zero full jitter that would hammer the cell that refused the dial.
const NOT_NEWER_RETRY_FLOOR_MS = 250
function directorEchoedCurrentAssignment(error: unknown, relay: PairingRelay): boolean {
return (
error instanceof RelayDirectorMoveNotNewerError &&
error.assignmentEpoch === relay.assignmentEpoch &&
@@ -151,5 +161,10 @@ function isDirectorRecoverable(error: unknown): boolean {
if (!(error instanceof RelayOuterError)) {
return true
}
// 4429 stays out: the cell rejects it after reserving the invite credential,
// so each retry burns an attempt toward cooldown/invalidation — and a
// director hop cannot relieve cell load anyway. The retained codes are
// rejected before reservation (4409/4503) or never reach the cell (1006),
// so their re-dials burn nothing.
return error.code === 4409 || error.code === 4503 || error.code === 1006
}
@@ -4,7 +4,7 @@ import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { racePairingCandidates } from './pairing-candidate-race'
import { startPreProfilePairing } from './pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, HostProfile, PairingOffer, RpcResponse } from './types'
import type { RpcClient } from './rpc-client'
import type { connect, RpcClient } from './rpc-client'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
@@ -97,7 +97,9 @@ function dependencies(client: RpcClient, events: string[]) {
new Error('relay unavailable')
)
return {
connectDirect: vi.fn(() => (events.push('connect'), client)),
connectDirect: vi.fn(
(..._args: Parameters<typeof connect>) => (events.push('connect'), client)
),
connectRelay: vi.fn(() => unavailableRelay),
resolveInviteDirector: vi.fn(async () => {
throw new Error('director unavailable')
@@ -382,6 +384,62 @@ describe('pre-profile pairing coordinator', () => {
expect(entries[2]).toMatchObject({ level: 'success', detail: 'winner: relay' })
})
it('attributes each racing candidate so direct retries cannot read as relay', async () => {
const entries: ConnectionLogEntry[] = []
const direct = fakeClient([])
;(direct.sendRequest as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('LAN down'))
let journal: MobileRelayPairingJournal | null = null
const relay = relayProvisioningClient(() => journal!)
const deps = dependencies(direct, [])
deps.saveJournal.mockImplementation(async (value) => {
journal = value
})
// Why: the reconnect lines the direct client emits carry the LAN address
// and no path label — the exact shape that was misread as relay retrying.
deps.connectDirect.mockImplementation((...args) => {
const options = args[3]
if (options && typeof options !== 'function') {
options.onLog?.({
id: 'direct-reconnect',
ts: now,
level: 'warn',
message: 'Reconnecting (attempt 2)',
detail: '10.5.0.2:6768'
})
}
return direct
})
deps.connectRelay.mockImplementation((connectArgs) => {
connectArgs.onLog?.({
id: 'relay-dial',
ts: now,
level: 'info',
message: 'Relay: dialing cell',
detail: 'relay-c1.onorca.dev'
})
connectArgs.onLog?.({ id: 'relay-open', ts: now, level: 'info', message: 'Cell socket open' })
return relay
})
const attempt = startPreProfilePairing({
offer: relayOffer,
timeoutMs: 5_000,
connectOptions: { onLog: (entry) => entries.push(entry) },
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(entries.map((entry) => entry.message)).toEqual([
'Direct: Reconnecting (attempt 2)',
'Relay: pairing candidate started',
// Already self-labelled: attribution must not stutter into 'Relay: Relay:'.
'Relay: dialing cell',
'Relay: Cell socket open',
'Pairing path selected'
])
expect(entries[0]).toMatchObject({ level: 'warn', detail: '10.5.0.2:6768' })
})
it('cancels the disposable physical client without publishing a host', async () => {
let resolveStatus!: (response: RpcResponse) => void
const status = new Promise<RpcResponse>((resolve) => {
@@ -26,6 +26,7 @@ import {
type PairingCandidateClient
} from './mobile-relay-physical-client'
import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race'
import { attributePairingLogPath } from './pairing-log-path'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import { createPairingRelayLogger } from './pairing-relay-log'
@@ -155,7 +156,7 @@ async function runPairing(
offer.endpoint,
offer.deviceToken,
offer.publicKeyB64,
connectOptions
{ ...connectOptions, onLog: attributePairingLogPath('direct', connectOptions?.onLog) }
)
clients.add(directClient)
const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }]
@@ -191,7 +192,7 @@ async function runPairing(
await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata)
},
now: dependencies.now,
onLog: connectOptions?.onLog
onLog: attributePairingLogPath('relay', connectOptions?.onLog)
})
clients.add(relayClient)
candidates.push({ path: 'relay', client: relayClient })
@@ -0,0 +1,14 @@
import type { HostClientStoreEntry } from './host-entry-opener'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
export function shouldPreserveActiveRelay(
entry: HostClientStoreEntry | undefined,
logical: Partial<StableLogicalRpcClient> | undefined
): boolean {
return Boolean(
entry &&
entry.state !== 'auth-failed' &&
!logical?.isPairingRejected?.() &&
logical?.getActivePath?.() === 'relay'
)
}
@@ -6,7 +6,10 @@ import {
EMPTY_RETIRED_NAME_REGISTRY,
type RetiredNameRegistry
} from '../../../src/shared/worktree/retired-name-registry'
import { useRetiredWorktreeNames } from './use-retired-worktree-names'
import {
buildRetiredWorktreeNamesRefreshKey,
useRetiredWorktreeNames
} from './use-retired-worktree-names'
type Pending = { resolve: (response: unknown) => void; reject: (err: Error) => void }
@@ -66,6 +69,22 @@ function mountNames() {
}
describe('useRetiredWorktreeNames', () => {
it('builds a stable refresh key without copied-array methods or input mutation', () => {
const paths = ['/repo/zebra', '/repo/antelope']
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toSorted')
Reflect.deleteProperty(Array.prototype, 'toSorted')
try {
expect(buildRetiredWorktreeNamesRefreshKey(paths)).toBe('/repo/antelope\0/repo/zebra')
expect(buildRetiredWorktreeNamesRefreshKey(undefined)).toBe('')
expect(paths).toEqual(['/repo/zebra', '/repo/antelope'])
} finally {
if (descriptor) {
Reflect.defineProperty(Array.prototype, 'toSorted', descriptor)
}
}
})
it('reads the selected repo out of the response envelope', async () => {
const probe = mountNames()
expect(probe.requests[0]).toEqual({
@@ -8,6 +8,12 @@ import {
import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry'
import type { RpcClient } from '../transport/rpc-client'
export function buildRetiredWorktreeNamesRefreshKey(
existingWorktreePaths: readonly string[] | undefined
): string {
return [...(existingWorktreePaths ?? [])].sort().join('\0')
}
/** Names already spent in a repo, including workspaces that have since been deleted.
*
* Why a targeted request rather than the workspace catalog: the catalog is served by `worktree.ps`,
+3
View File
@@ -31,6 +31,7 @@
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"build:orcad": "node config/scripts/build-orcad.mjs",
"build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs",
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
@@ -110,6 +111,8 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
"test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs",
"test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs",
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
"test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs",
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+3 -3
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
'@vscode/windows-process-tree@0.8.0':
hash: 7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05
hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
@@ -407,7 +407,7 @@ importers:
optionalDependencies:
'@vscode/windows-process-tree':
specifier: 0.8.0
version: 0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)
version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)
sherpa-onnx-darwin-arm64:
specifier: 1.12.37
version: 1.12.37
@@ -9605,7 +9605,7 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/windows-process-tree@0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)':
'@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)':
dependencies:
node-addon-api: 7.1.0
optional: true
+2
View File
@@ -367,12 +367,14 @@ Browser rules:
- Prefer `wait --text`, `--url`, `--selector`, or `--load` after async page changes instead of bare timeouts.
- Less common workflows can use typed commands above or `orca exec --command "<agent-browser command>"` passthrough.
- If `fill` or `type` fails on a custom input, try `orca focus --element @e1 --json` then `orca inserttext --text "text" --json`.
- Client-hosted pages have interactive-session affinity: the page renders in the paired desktop's own browser engine, so every command against it needs that desktop online and returns `browser_host_unavailable` when it is closed, asleep, or disconnected. Server-hosted pages keep running with no desktop attached, so prefer server placement for long-running or unattended browser automation.
Common recoveries:
- `browser_no_tab`: open a tab with `orca tab create --url <url> --json`.
- `browser_stale_ref`: run `orca snapshot --json` and retry with fresh refs.
- `browser_tab_not_found`: run `orca tab list --json` before switching or closing.
- `browser_host_unavailable`: the desktop hosting that page is offline. Bring it back, or create the page for server placement when the work must survive without an interactive session.
## Next Action
+27
View File
@@ -176,6 +176,33 @@ Dispatch rules:
- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.
- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.
## How deep workers can nest
A dispatched worker normally cannot dispatch sub-workers. Attempting it fails with
`nested_worker_depth_exceeded` and a message telling the worker to complete the task
itself. Do that — do not try to route around it.
The limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth`
sets how many generations are allowed:
- `1` (default): a coordinator dispatches workers; those workers do not dispatch.
- `2`: workers may dispatch one further generation.
Depth is counted from the terminal that issues the command, not from the Run. Creating a
new Run does not reset it — a worker that runs `run-create` then `worker-start` is still a
worker, and still counted. This is the part that changed: the old behaviour rejected
sub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was
enough to slip past it.
Two limits worth knowing:
- **It is a guardrail, not a security boundary.** A caller that declares another terminal's
handle while its own launch evidence is unverifiable (an ordinary restored terminal, for
example) can be counted as that terminal instead. Orca does not treat workers as hostile.
- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator
settles the task, the terminal is no longer a worker and is counted as a root again. The
process may still be alive; that is the documented boundary, not an accident.
## Preferred Supervised Worker Loop
Use `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.

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