diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index b9d3dea8629..605645186fb 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -871,6 +871,12 @@ jobs: node config/scripts/runtime-file-watcher-fault-harness.mjs ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs + - name: Gate SSH relay watcher process isolation + run: | + # Why: the remote native watcher shares a daemon with live PTYs. + # Kill only its child and require both PTY and watch recovery before packaging. + node config/scripts/relay-watcher-fault-harness.mjs + - name: Publish release artifacts (Linux) if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64' uses: nick-fields/retry@v4 diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index d08584c1077..bd5c5dc9c00 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -127,6 +127,12 @@ jobs: node config/scripts/runtime-file-watcher-fault-harness.mjs ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs + - name: Gate SSH relay watcher process isolation + run: | + # Why: the remote native watcher shares a daemon with live PTYs. + # Kill only its child and require both PTY and watch recovery before packaging. + node config/scripts/relay-watcher-fault-harness.mjs + - name: Publish release artifacts (macOS) uses: nick-fields/retry@v4 with: diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index d9ee50773e4..bf6ef13b36e 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3750,7 +3750,7 @@ }, { "id": "runtime-files.watcher-process-isolation", - "title": "Runtime file watcher faults stay process-isolated and recover without host starvation", + "title": "Runtime and SSH relay watcher faults stay process-isolated without disrupting host services", "maturity": "experimental", "protection": "partial", "owner": "runtime-platform", @@ -3759,7 +3759,8 @@ "headless orca serve", "paired file explorer", "runtime editor external-change detection", - "desktop filesystem watcher" + "desktop filesystem watcher", + "SSH relay filesystem watcher and live PTYs" ], "platforms": [ "macos", @@ -3779,19 +3780,22 @@ "remote-runtime", "ssh" ], - "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool plus at most four bounded quarantine children, one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. Windows keeps the existing fs.watch runtime path; SSH shared-watch setup uses registration-owned cancellation so one caller cannot cancel another caller's watch.", + "coverageNotes": "Deterministic tests cover a four-child healthy runtime pool plus at most four bounded quarantine children, one quarantine attempt per watch lifetime, queue-aware cancellation, bounded initial and crash-resubscribe crawls, physical desktop-install abort, stale-generation rejection, per-child stat bounds, final-overflow RPC delivery, and renderer eviction before terminal callbacks can retry. A built-entry macOS harness drives the real supervisor, kills its child with SIGSEGV, then requires automatic resubscription and a later event. SSH now bundles the same child boundary beside relay.js, requires both artifacts for install completeness, and preserves registration-owned same-root cancellation. A built-relay harness kills that child and requires the original relay PID and existing PTY to survive before overflow and precise events resume.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/5308", "https://github.com/stablyai/orca/issues/7547", "https://github.com/stablyai/orca/issues/8212" ], - "invariant": "Local runtime roots use at most four shared watcher children while healthy and at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, directory metadata work stays bounded to eight concurrent operations per child, destructive desktop cleanup physically aborts pending native setup, and terminal renderer watches are evicted before error callbacks can retry.", - "oracle": "Fill the four-child healthy pool with multiple roots, inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Synchronously retry from a renderer terminal-error callback and require a fresh stream that survives the old stream's end. Run eight simultaneous metadata batches in one child and assert no more than eight stats are active there. Drive a real built child through the source supervisor, kill it with SIGSEGV, and require automatic resubscription plus a post-crash event while the harness parent remains alive.", + "invariant": "Local runtime and SSH relay roots use at most four shared watcher children while healthy and at most four additional quarantine children after shard failure. Cancelling queued setup removes only that request; cancelling an active crawl restarts one shard and restores its healthy records. Setup deadlines start only when the child owns the crawl; the runtime RPC transport owns the normal 15-second initial budget, while a 60-second recovery deadline quarantines a root that would otherwise pin its shard. A crash-fused shared shard moves affected roots into the bounded quarantine pool, and a second fused quarantine ends those watch lifetimes instead of spawning replacements indefinitely. Every interrupted setup emits overflow after recovery, event delivery stays bounded, and the SSH relay, PTYs, Git, and other filesystem operations never load the watcher native addon or share its fault boundary.", + "oracle": "Fill the four-child healthy pool with multiple roots, inject IPC disconnect-before-exit, queued and active setup cancellation, initial and crash-resubscribe timeouts, and shard-level terminal failure, then assert healthy roots are restored, affected roots spread across no more than four quarantine supervisors, a second quarantine fuse creates no replacement, stale generations stop delivering, and existing same-root clients receive overflow plus later events without error/end. Abort a pending desktop install and require worktree close and app shutdown to settle before the native crawl resolves. Verify relay build hashes and remote install probes require both relay.js and relay-watcher.js. Launch the built relay with a real PTY and Parcel watch, kill only the watcher child, and require the same relay PID and PTY to answer before a replacement child delivers overflow and a post-crash event.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts", "pnpm run build:electron-vite", "node config/scripts/runtime-file-watcher-fault-harness.mjs", - "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs" + "ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs", + "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "pnpm run build:relay", + "node config/scripts/relay-watcher-fault-harness.mjs" ], "testFiles": [ "src/main/ipc/parcel-watcher-process.test.ts", @@ -3804,7 +3808,13 @@ "src/main/runtime/rpc/methods/files.test.ts", "src/main/providers/ssh-filesystem-provider.test.ts", "src/renderer/src/runtime/runtime-file-client.test.ts", - "config/scripts/runtime-file-watcher-fault-harness.mjs" + "config/scripts/runtime-file-watcher-fault-harness.mjs", + "src/relay/relay-filesystem-watch-registry.test.ts", + "src/relay/fs-handler.test.ts", + "src/relay/subprocess.test.ts", + "src/main/ssh/ssh-relay-versioned-install.test.ts", + "config/scripts/package-electron-runtime-contract.test.mjs", + "config/scripts/relay-watcher-fault-harness.mjs" ], "assertionRefs": [ { @@ -3814,7 +3824,8 @@ "IPC disconnect recovery restores every existing root before accepting new setup", "queued abort removes one request without restarting healthy roots, while active abort or crawl-scoped timeout restores the shard", "a crash-resubscribe crawl deadline ends only the stuck root and tears down its shard process", - "missing built entries fail closed and unavailable canary storage remains optional" + "missing built entries fail closed and unavailable canary storage remains optional", + "fault-harness PID reporting cannot clobber an existing filesystem path" ] }, { @@ -3885,6 +3896,39 @@ "a later same-root listener opens a fresh server subscription instead of reusing a dead stream" ] }, + { + "file": "src/relay/relay-filesystem-watch-registry.test.ts", + "assertions": [ + "replacement emits overflow before later ordered events", + "terminal shard failure preserves same-root clients through recovery", + "pending setup aborts only after the last same-root client leaves", + "a missing child fails closed even if the relay inherits a VITEST environment" + ] + }, + { + "file": "src/relay/fs-handler.test.ts", + "assertions": [ + "shared watches retain the twenty-root budget and release after every client leaves" + ] + }, + { + "file": "src/relay/subprocess.test.ts", + "assertions": [ + "the real relay bundle serves PTY, filesystem, and Git requests with its adjacent watcher child" + ] + }, + { + "file": "src/main/ssh/ssh-relay-versioned-install.test.ts", + "assertions": [ + "remote completeness requires relay.js, relay-watcher.js, and the install sentinel" + ] + }, + { + "file": "config/scripts/package-electron-runtime-contract.test.mjs", + "assertions": [ + "every release platform packages and gates the hashed watcher child" + ] + }, { "file": "config/scripts/runtime-file-watcher-fault-harness.mjs", "assertions": [ @@ -3892,17 +3936,42 @@ "the harness parent survives watcher-child SIGSEGV", "the real supervisor automatically replaces the child, resubscribes, and delivers a post-crash event" ] + }, + { + "file": "config/scripts/relay-watcher-fault-harness.mjs", + "assertions": [ + "the built relay and existing PTY survive watcher-child fault injection", + "replacement emits overflow and a precise post-crash event" + ] } ], "evidenceRuns": [ + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/relay-filesystem-watch-registry.test.ts src/relay/fs-handler.test.ts src/relay/subprocess.test.ts src/main/ssh/ssh-relay-versioned-install.test.ts config/scripts/package-electron-runtime-contract.test.mjs", + "result": "passed", + "durationSeconds": 4.5, + "summary": "Five relay registry, handler, bundled subprocess, install, and package contract files passed with 114 tests." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "node config/scripts/relay-watcher-fault-harness.mjs", + "result": "passed", + "durationSeconds": 0.61, + "summary": "The relay PID and existing PTY survived watcher-child SIGSEGV; overflow and precise events resumed from a replacement child." + }, { "date": "2026-07-12", "runner": "local", "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/parcel-watcher-process.test.ts src/main/ipc/runtime-watcher-process-pool.test.ts src/main/ipc/parcel-watcher-process-entry.test.ts src/main/ipc/parcel-watcher-event-delivery.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-wsl.test.ts src/main/runtime/file-watcher-host.test.ts src/main/runtime/rpc/methods/files.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/renderer/src/runtime/runtime-file-client.test.ts", "result": "passed", - "durationSeconds": 1.73, - "summary": "10 focused supervisor, pool, child-entry, delivery, desktop/WSL cleanup, runtime-host, RPC, SSH ownership, and renderer-client test files passed with 222 tests." + "durationSeconds": 1.7, + "summary": "10 focused supervisor, pool, child-entry, delivery, desktop/WSL cleanup, runtime-host, RPC, SSH ownership, and renderer-client test files passed with 223 tests." }, { "date": "2026-07-12", diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index c9cabcea838..cb7e01c9700 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node /** - * Bundle the relay daemon into a single relay.js file per platform. + * Bundle the relay daemon and its crash-isolated watcher child per platform. * - * The relay runs on remote hosts via `node relay.js`, so it must be a - * self-contained CommonJS bundle with no external dependencies beyond + * The relay runs on remote hosts via `node relay.js`, so both outputs use + * self-contained CommonJS bundles with no external dependencies beyond * Node.js built-ins. Native addons (node-pty, @parcel/watcher) are * marked external and expected to be installed on the remote or * gracefully degraded. @@ -17,6 +17,7 @@ const __dirname = import.meta.dirname // Why: the script lives under config/scripts, so go two levels up to reach the repo root. const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') +const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') const PLATFORMS = [ 'linux-x64', @@ -42,7 +43,22 @@ for (const platform of PLATFORMS) { outfile: join(outDir, 'relay.js'), // Native addons cannot be bundled — they must exist on the remote host. // The relay gracefully degrades when they are absent. - external: ['node-pty', '@parcel/watcher'], + external: ['node-pty', '@parcel/watcher', 'electron'], + sourcemap: false, + minify: true, + define: { + 'process.env.NODE_ENV': '"production"' + } + }) + + await build({ + entryPoints: [WATCHER_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, 'relay-watcher.js'), + external: ['@parcel/watcher'], sourcemap: false, minify: true, define: { @@ -51,9 +67,15 @@ for (const platform of PLATFORMS) { }) // Why: include a content hash so the deploy check detects code changes - // even when RELAY_VERSION hasn't been bumped (common during development). + // even when RELAY_VERSION hasn't been bumped. Hash both process artifacts + // so a watcher-only change always deploys beside the matching relay host. const relayContent = readFileSync(join(outDir, 'relay.js')) - const hash = createHash('sha256').update(relayContent).digest('hex').slice(0, 12) + const watcherContent = readFileSync(join(outDir, 'relay-watcher.js')) + const hash = createHash('sha256') + .update(relayContent) + .update(watcherContent) + .digest('hex') + .slice(0, 12) writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`) console.log(`Built relay for ${platform} → ${outDir}/relay.js`) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 241cad088d1..e4a118a311f 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -122,6 +122,46 @@ describe('Electron runtime package contract', () => { ) }) + it('packages and release-gates the SSH relay watcher child', () => { + const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8') + const builderConfig = readFileSync( + join(projectDir, 'config/electron-builder.config.cjs'), + 'utf8' + ) + const remoteCommands = readFileSync( + join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'), + 'utf8' + ) + const releaseWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) + const macWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8') + ) + + expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") + expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") + expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))") + expect(builderConfig).toContain("from: 'out/relay'") + expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')") + + const assertRelayGate = (steps, publishStepName) => { + const names = steps.map((step) => step.name) + const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation') + expect(gate['continue-on-error']).toBeUndefined() + expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs') + expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name)) + expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName)) + } + + assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)') + assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)') + const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name) + expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan( + releaseNames.indexOf('Build Windows release artifacts') + ) + }) + it('pins the Windows release builder to the VS 2022 runner image', () => { const releaseWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs new file mode 100644 index 00000000000..c86ba764979 --- /dev/null +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -0,0 +1,286 @@ +import { build } from 'esbuild' +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +const WAIT_TIMEOUT_MS = 20_000 +const require = createRequire(import.meta.url) + +function withTimeout(promise, label, stderr) { + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { + rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`)) + }, WAIT_TIMEOUT_MS) + promise.then( + (value) => { + clearTimeout(timer) + resolvePromise(value) + }, + (error) => { + clearTimeout(timer) + rejectPromise(error) + } + ) + }) +} + +function pollUntil(readValue, label, stderr) { + const deadline = Date.now() + WAIT_TIMEOUT_MS + return new Promise((resolveValue, rejectValue) => { + const poll = async () => { + try { + const value = await readValue() + if (value !== undefined) { + resolveValue(value) + return + } + } catch {} + if (Date.now() >= deadline) { + rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`)) + return + } + setTimeout(poll, 10) + } + void poll() + }) +} + +function waitForExit(proc) { + if (proc.exitCode !== null || proc.signalCode !== null) { + return Promise.resolve() + } + return new Promise((resolveExit) => proc.once('exit', resolveExit)) +} + +async function loadProtocol(bundleDir) { + const outfile = join(bundleDir, 'relay-protocol.cjs') + await build({ + entryPoints: [resolve('src/relay/protocol.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + logLevel: 'silent' + }) + return require(outfile) +} + +function createRelayClient(entryPath, args, env, protocol) { + const proc = spawn(process.execPath, [entryPath, ...args], { + cwd: dirname(entryPath), + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + const messages = [] + let stderr = '' + let nextSequence = 1 + let stdoutBuffer = Buffer.alloc(0) + let ready = false + let resolveReady + const sentinelReceived = new Promise((resolvePromise) => { + resolveReady = resolvePromise + }) + const decoder = new protocol.FrameDecoder((frame) => { + if (frame.type === protocol.MessageType.Regular) { + messages.push(protocol.parseJsonRpcMessage(frame.payload)) + } + }) + proc.stderr.on('data', (chunk) => { + stderr = `${stderr}${String(chunk)}`.slice(-8_000) + }) + proc.stdout.on('data', (chunk) => { + if (ready) { + decoder.feed(chunk) + return + } + stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) + const sentinel = Buffer.from(protocol.RELAY_SENTINEL) + const index = stdoutBuffer.indexOf(sentinel) + if (index < 0) { + return + } + ready = true + resolveReady() + const remainder = stdoutBuffer.subarray(index + sentinel.length) + if (remainder.length > 0) { + decoder.feed(remainder) + } + }) + + const waitForMessage = (startIndex, predicate, label) => + pollUntil( + () => messages.slice(startIndex).find(predicate), + label, + () => stderr + ) + + const request = async (method, params = {}) => { + const id = nextSequence++ + const startIndex = messages.length + proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) + const response = await waitForMessage( + startIndex, + (message) => message.id === id, + `response to ${method}` + ) + if (response.error) { + throw new Error(`${method} failed: ${response.error.message}`) + } + return response.result + } + + const notify = (method, params = {}) => { + const sequence = nextSequence++ + proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0)) + } + + return { + proc, + request, + notify, + sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr), + messageCount: () => messages.length, + waitForNotification: (startIndex, method, predicate = () => true) => + waitForMessage( + startIndex, + (message) => message.method === method && predicate(message.params ?? {}), + `${method} notification` + ), + stderr: () => stderr + } +} + +async function waitForWatcherPid(pidFile, previousPid, stderr) { + return pollUntil( + async () => { + const pid = Number((await readFile(pidFile, 'utf8')).trim()) + if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) { + return undefined + } + // Why: replacement children reuse this exclusive path after fault injection. + await rm(pidFile, { force: true }) + return pid + }, + previousPid ? 'replacement watcher child pid' : 'initial watcher child pid', + stderr + ) +} + +function includesWatchPath(params, targetPath) { + return Array.isArray(params.events) + ? params.events.some((event) => event.absolutePath === targetPath) + : false +} + +async function main() { + const platform = `${process.platform}-${process.arch}` + const relayEntry = resolve('out', 'relay', platform, 'relay.js') + const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js') + if (!existsSync(relayEntry) || !existsSync(watcherEntry)) { + throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`) + } + + let tempRoot + let relay + try { + tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-')) + const watchRoot = await realpath(tempRoot) + const pidFile = join(tempRoot, 'watcher.pid') + const protocol = await loadProtocol(tempRoot) + const socketPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}` + : join(tempRoot, 'relay.sock') + relay = createRelayClient( + relayEntry, + ['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')], + { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile }, + protocol + ) + await relay.sentinelReceived + + const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot }) + const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}` + let startIndex = relay.messageCount() + relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` }) + await relay.waitForNotification( + startIndex, + 'pty.data', + (params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker) + ) + + await relay.request('fs.watch', { rootPath: watchRoot }) + const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr) + const beforePath = join(watchRoot, 'before.txt') + startIndex = relay.messageCount() + await writeFile(beforePath, 'before') + await relay.waitForNotification(startIndex, 'fs.changed', (params) => + includesWatchPath(params, beforePath) + ) + + const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' + startIndex = relay.messageCount() + process.kill(firstWatcherPid, faultSignal) + const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr) + await relay.waitForNotification(startIndex, 'fs.changed', (params) => + Array.isArray(params.events) + ? params.events.some( + (event) => event.kind === 'overflow' && event.absolutePath === watchRoot + ) + : false + ) + + const status = await relay.request('relay.status') + if (status.pid !== relay.proc.pid) { + throw new Error('relay.status did not come from the original surviving relay process') + } + const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}` + startIndex = relay.messageCount() + relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` }) + await relay.waitForNotification( + startIndex, + 'pty.data', + (params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker) + ) + + const afterPath = join(watchRoot, 'after.txt') + startIndex = relay.messageCount() + await writeFile(afterPath, 'after') + await relay.waitForNotification(startIndex, 'fs.changed', (params) => + includesWatchPath(params, afterPath) + ) + + relay.notify('fs.unwatch', { rootPath: watchRoot }) + await relay.request('pty.shutdown', { id: spawned.id }) + console.log( + JSON.stringify({ + relayPid: relay.proc.pid, + killedWatcherPid: firstWatcherPid, + replacementWatcherPid, + faultSignal, + relaySurvived: true, + existingPtySurvived: true, + overflowRefreshDelivered: true, + postCrashEventDelivered: true + }) + ) + } finally { + if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) { + relay.proc.kill('SIGTERM') + try { + await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr) + } catch { + relay.proc.kill('SIGKILL') + await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr) + } + } + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }) + } + } +} + +await main() diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx index ade35feda45..6c47fce7a5b 100644 --- a/mobile/src/components/MobileAgentIcon.tsx +++ b/mobile/src/components/MobileAgentIcon.tsx @@ -1,8 +1,11 @@ import { Image, StyleSheet, Text, View } from 'react-native' +import type { ImageSourcePropType } from 'react-native' import { Terminal } from 'lucide-react-native' +import type { TuiAgent } from '../../../src/shared/types' import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg' import { colors } from '../theme/mobile-theme' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' +import { MOBILE_AGENT_ICON_ASSETS } from './mobile-agent-icon-assets' import { ClaudeIcon, OpenAIIcon } from './AgentIcons' // Why: agent branding should match the desktop/new-worktree picker everywhere @@ -49,6 +52,10 @@ function AiderIcon({ size = 16 }: { size?: number }) { ) } +function BundledIcon({ source, size = 16 }: { source: ImageSourcePropType; size?: number }) { + return +} + function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) { return ( } + // Why: prefer the favicon bundled into the app over Google's favicon service, + // which is unreachable in some regions and offline (#8451). + const bundledIcon = MOBILE_AGENT_ICON_ASSETS[agentId as TuiAgent] + if (bundledIcon) { + return + } const agent = MOBILE_AGENT_CATALOG.find((entry) => entry.id === agentId) if (agent?.faviconDomain) { return diff --git a/mobile/src/components/mobile-agent-icon-assets.test.ts b/mobile/src/components/mobile-agent-icon-assets.test.ts new file mode 100644 index 00000000000..edbe71760c7 --- /dev/null +++ b/mobile/src/components/mobile-agent-icon-assets.test.ts @@ -0,0 +1,45 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import type { TuiAgent } from '../../../src/shared/types' +import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents' + +// Agents that render a hand-authored glyph in MobileAgentIcon and therefore +// never reach the favicon/bundled-asset path. +const GLYPH_AGENT_IDS = new Set([ + 'claude', + 'claude-agent-teams', + 'codex', + 'pi', + 'omp', + 'aider' +]) + +const dirname = import.meta.dirname +const assetsDir = path.resolve(dirname, '../../../src/shared/agent-icons') +const assetsModuleSource = readFileSync(path.join(dirname, 'mobile-agent-icon-assets.ts'), 'utf8') + +// Why: these agents previously loaded from Google's favicon service, which is +// unreachable in some regions/offline (#8451). Every one that lacks a glyph +// must ship a bundled PNG so the icon renders without a network request. +const agentsNeedingBundledIcon = Object.keys(MOBILE_TUI_AGENT_FAVICON_DOMAINS).filter( + (id): id is TuiAgent => !GLYPH_AGENT_IDS.has(id as TuiAgent) +) + +describe('mobile bundled agent icons', () => { + it('ships a bundled PNG on disk for every favicon-path agent', () => { + for (const id of agentsNeedingBundledIcon) { + expect(existsSync(path.join(assetsDir, `${id}.png`)), `missing bundled icon for ${id}`).toBe( + true + ) + } + }) + + it('wires every favicon-path agent into the bundled asset map', () => { + for (const id of agentsNeedingBundledIcon) { + expect(assetsModuleSource, `${id} not mapped in mobile-agent-icon-assets.ts`).toContain( + `agent-icons/${id}.png` + ) + } + }) +}) diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts new file mode 100644 index 00000000000..52615e89df8 --- /dev/null +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -0,0 +1,41 @@ +import type { ImageSourcePropType } from 'react-native' +import type { TuiAgent } from '../../../src/shared/types' + +// Why: mobile previously rendered these agent icons from Google's favicon +// service, which is unreachable in some regions (e.g. mainland China) and +// offline, leaving broken images in the agent picker (#8451). Bundle each +// favicon so Metro packages it into the app and the icon renders without any +// network request. The PNGs are shared with desktop in src/shared/agent-icons +// (Metro watches src/shared via metro.config.js sharedRoot). Metro requires +// static literal require() paths, so keep this as an explicit map rather than +// building the path from the agent id. +export const MOBILE_AGENT_ICON_ASSETS: Partial> = { + openclaude: require('../../../src/shared/agent-icons/openclaude.png'), + grok: require('../../../src/shared/agent-icons/grok.png'), + copilot: require('../../../src/shared/agent-icons/copilot.png'), + opencode: require('../../../src/shared/agent-icons/opencode.png'), + 'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'), + ante: require('../../../src/shared/agent-icons/ante.png'), + gemini: require('../../../src/shared/agent-icons/gemini.png'), + antigravity: require('../../../src/shared/agent-icons/antigravity.png'), + goose: require('../../../src/shared/agent-icons/goose.png'), + amp: require('../../../src/shared/agent-icons/amp.png'), + kilo: require('../../../src/shared/agent-icons/kilo.png'), + kiro: require('../../../src/shared/agent-icons/kiro.png'), + crush: require('../../../src/shared/agent-icons/crush.png'), + aug: require('../../../src/shared/agent-icons/aug.png'), + autohand: require('../../../src/shared/agent-icons/autohand.png'), + cline: require('../../../src/shared/agent-icons/cline.png'), + codebuff: require('../../../src/shared/agent-icons/codebuff.png'), + 'command-code': require('../../../src/shared/agent-icons/command-code.png'), + continue: require('../../../src/shared/agent-icons/continue.png'), + cursor: require('../../../src/shared/agent-icons/cursor.png'), + droid: require('../../../src/shared/agent-icons/droid.png'), + kimi: require('../../../src/shared/agent-icons/kimi.png'), + 'mistral-vibe': require('../../../src/shared/agent-icons/mistral-vibe.png'), + 'qwen-code': require('../../../src/shared/agent-icons/qwen-code.png'), + rovo: require('../../../src/shared/agent-icons/rovo.png'), + hermes: require('../../../src/shared/agent-icons/hermes.png'), + devin: require('../../../src/shared/agent-icons/devin.png'), + openclaw: require('../../../src/shared/agent-icons/openclaw.png') +} diff --git a/package.json b/package.json index e361e666045..64c06de412a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.138-rc.4", + "version": "1.4.138-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts new file mode 100644 index 00000000000..269dfd7a47a --- /dev/null +++ b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, it, vi } from 'vitest' + +const callMock = vi.fn() +const originalExitCode = process.exitCode +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { printResult } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +afterEach(() => { + process.exitCode = originalExitCode +}) + +it('prints a lifecycle rejection and exits unsuccessfully', async () => { + const response = { + result: { + message: { id: 'msg_rejected' }, + lifecycle: { + action: 'rejected' as const, + code: 'sender_not_assignee', + reason: 'dispatch ctx_1 expected the assigned pane' + } + } + } + callMock.mockResolvedValueOnce(response) + + await ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_foreign'], + ['to', 'term_coord'], + ['subject', 'done'], + ['type', 'worker_done'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + + expect(process.exitCode).toBe(1) + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + expect(formatter?.(response.result)).toBe( + 'Rejected msg_rejected: dispatch ctx_1 expected the assigned pane' + ) +}) diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index 735a72a53cf..2a8f6d44dfb 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -317,6 +317,26 @@ describe('orchestration send structured payload flags', () => { }) expect(callMock).not.toHaveBeenCalled() }) + + it.each(['worker_done', 'heartbeat'] as const)( + 'does not resolve an identity-less %s sender from the active terminal', + async (type) => { + getTerminalHandleMock.mockResolvedValue('term_active_coordinator') + + await expect( + invokeSend( + new Map([ + ['to', 'term_coord'], + ['subject', 'update'], + ['type', type] + ]) + ) + ).rejects.toMatchObject({ code: 'no_active_sender_terminal' }) + + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + } + ) }) describe('orchestration dispatch coordinator handle', () => { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index d215fcb2e2e..fc285c56440 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -80,6 +80,16 @@ type MessageSummary = { read?: number } +type LifecycleSendRejection = { + action: 'rejected' + code: string + reason: string +} + +type OrchestrationSendResult = + | { message: { id: string }; lifecycle?: LifecycleSendRejection } + | { messages: { id: string }[]; recipients: number } + function getOptionalStructuredMessagePayload( flags: Map ): string | undefined { @@ -347,6 +357,16 @@ export const ORCHESTRATION_HANDLERS: Record = { const type = getOptionalStringFlag(flags, 'type') rejectLifecycleGroupRecipient(type, to) + if ( + (type === 'worker_done' || type === 'heartbeat') && + !getOptionalStringFlag(flags, 'from') && + !process.env.ORCA_TERMINAL_HANDLE + ) { + // Why: focus is not lifecycle authority; injected dispatches carry an + // explicit worker handle so an identity-less subprocess must fail closed. + throwNoActiveSenderTerminal() + } + // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness // probe (terminal.show throws runtime_unavailable in the exact mid-restart // window worker_done must survive) and no pane remint (pre-payload- @@ -354,9 +374,7 @@ export const ORCHESTRATION_HANDLERS: Record = { // and coordinator replies route to the sender row while the worker's own // `check` reads its env-handle inbox). const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await client.call< - { message: { id: string } } | { messages: { id: string }[]; recipients: number } - >('orchestration.send', { + const result = await client.call('orchestration.send', { from, to, subject: getRequiredStringFlag(flags, 'subject'), @@ -370,8 +388,16 @@ export const ORCHESTRATION_HANDLERS: Record = { senderPaneKey: process.env.ORCA_PANE_KEY || undefined, devMode: isDevCliInvocation() }) + if ('message' in result.result && result.result.lifecycle?.action === 'rejected') { + // Why: a persisted diagnostic is not a successful lifecycle signal; + // the non-zero status prevents workers from treating it as completion. + process.exitCode = 1 + } printResult(result, json, (r) => { if ('message' in r) { + if (r.lifecycle?.action === 'rejected') { + return `Rejected ${r.message.id}: ${r.lifecycle.reason}` + } return `Sent ${r.message.id}` } return `Sent ${r.messages.length} messages to ${r.recipients} recipients` diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 05138e2f993..5a4fd2b7f25 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() @@ -18,6 +19,57 @@ class FakeChildProcess extends EventEmitter { unref = vi.fn() } +const RECIPE_JSON = JSON.stringify({ + schemaVersion: 1, + pairingCode: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'wss://sandbox.example.com', + deviceToken: 'token', + publicKeyB64: 'public-key' + }), + projectRoot: '/workspace/repo' +}) +const SERVE_INSTALL_STATUS = '[serve] orca CLI install: installed' +const SSH_PRIVATE_KEY = 'TOP-SECRET-PRIVATE-KEY' +const SSH_AUTHORIZATION = 'Bearer TOP-SECRET-AUTHORIZATION' +const SSH_PASSPHRASE = 'TOP-SECRET-PASSPHRASE' +const SSH_COOKIE = 'session=TOP-SECRET-COOKIE' +const SSH_RECIPE_JSON = JSON.stringify({ + schemaVersion: 1, + connection: { + type: 'ssh', + target: { + label: 'Sandbox', + host: 'sandbox.example.com', + port: 22, + username: 'root' + }, + projectRoot: '/workspace/repo' + }, + userData: { + credentials: { + privateKey: SSH_PRIVATE_KEY, + authorization: SSH_AUTHORIZATION, + passphrase: SSH_PASSPHRASE, + cookie: SSH_COOKIE + } + } +}) +const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'relative/repo') +const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' + +function startRecipeJsonServer() { + const child = new FakeChildProcess() + spawnMock.mockReturnValue(child) + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const result = serveOrcaApp({ + recipeJson: true, + projectRoot: '/workspace/repo' + }) + return { child, result, stdoutSpy, stderrSpy } +} + describe('serveOrcaApp', () => { beforeEach(() => { spawnMock.mockReset() @@ -133,10 +185,7 @@ describe('serveOrcaApp', () => { projectRoot: '/workspace/repo' }) queueMicrotask(() => { - child.stdout.emit( - 'data', - '{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n' - ) + child.stdout.emit('data', `${RECIPE_JSON}\n`) }) await expect(result).resolves.toBe(0) @@ -157,12 +206,92 @@ describe('serveOrcaApp', () => { stdio: ['ignore', 'pipe', 'inherit'] }) ) - expect(writeSpy).toHaveBeenCalledWith( - '{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n' - ) + expect(writeSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) expect(child.unref).toHaveBeenCalled() }) + it('waits past startup status lines for valid recipe JSON', async () => { + const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer() + queueMicrotask(() => { + child.stdout.emit( + 'data', + `${SERVE_INSTALL_STATUS}\n${INVALID_SSH_RECIPE_JSON}\n${SSH_RECIPE_JSON}\n${RECIPE_JSON.slice(0, 40)}` + ) + child.stdout.emit('data', `${RECIPE_JSON.slice(40)}\n`) + }) + + await expect(result).resolves.toBe(0) + + expect(stderrSpy).toHaveBeenNthCalledWith(1, `${IGNORED_NON_RECIPE_STDOUT}\n`) + expect(stderrSpy).toHaveBeenNthCalledWith(2, `${IGNORED_NON_RECIPE_STDOUT}\n`) + expect(stderrSpy).toHaveBeenNthCalledWith(3, `${IGNORED_NON_RECIPE_STDOUT}\n`) + for (const secret of [SSH_PRIVATE_KEY, SSH_AUTHORIZATION, SSH_PASSPHRASE, SSH_COOKIE]) { + expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret)) + } + expect(stdoutSpy).toHaveBeenCalledTimes(1) + expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('preserves UTF-8 recipe JSON split across Buffer chunks', async () => { + const { child, result, stdoutSpy } = startRecipeJsonServer() + const unicodeRecipeJson = RECIPE_JSON.replace('/workspace/repo', '/workspace/café') + const recipeBuffer = Buffer.from(`${unicodeRecipeJson}\n`) + const splitIndex = recipeBuffer.indexOf(Buffer.from('é')) + 1 + queueMicrotask(() => { + child.stdout.emit('data', recipeBuffer.subarray(0, splitIndex)) + child.stdout.emit('data', recipeBuffer.subarray(splitIndex)) + }) + + await expect(result).resolves.toBe(0) + + expect(stdoutSpy).toHaveBeenCalledWith(`${unicodeRecipeJson}\n`) + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('rejects when the server exits without valid recipe JSON', async () => { + const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer() + const secrets = ['UPPER-SECRET', 'SLASH-SECRET', 'LEGACY-SECRET', 'PRIVATE-SECRET'] + const untrustedLines = [ + 'ORCA://pair?code=UPPER-SECRET', + 'orca://pair/?code=SLASH-SECRET', + 'orca://pair#LEGACY-SECRET', + '"embedded privateKey PRIVATE-SECRET"', + '{privateKey:"PRIVATE-SECRET"}' + ].join('\n') + queueMicrotask(() => { + child.stdout.emit('data', `${untrustedLines}\n`) + child.emit('exit', 0, null) + child.emit('close', 0, null) + }) + + await expect(result).rejects.toMatchObject({ + code: 'runtime_serve_failed', + message: 'Orca serve exited before printing valid recipe JSON with code 0.' + }) + expect(stdoutSpy).not.toHaveBeenCalled() + expect(stderrSpy).toHaveBeenCalledTimes(5) + expect(stderrSpy).toHaveBeenCalledWith(`${IGNORED_NON_RECIPE_STDOUT}\n`) + for (const secret of secrets) { + expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret)) + } + expect(child.unref).not.toHaveBeenCalled() + }) + + it('accepts valid recipe JSON at exit without a trailing newline', async () => { + const { child, result, stdoutSpy } = startRecipeJsonServer() + queueMicrotask(() => { + child.emit('exit', 0, null) + child.stdout.emit('data', RECIPE_JSON) + child.emit('close', 0, null) + }) + + await expect(result).resolves.toBe(0) + + expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) + expect(child.unref).toHaveBeenCalledOnce() + }) + it('uses a shell when a Windows npm command shim is the Electron executable', async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index 29b5d561b16..cdb20f194ab 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,7 +1,14 @@ import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process' import { dirname, resolve } from 'node:path' +import { StringDecoder } from 'node:string_decoder' +import { + getEphemeralVmRecipeResultConnection, + parseEphemeralVmRecipeResult +} from '../../shared/ephemeral-vm-recipes' import { RuntimeClientError } from './types' +const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' + export function launchOrcaApp(): void { const overrideCommand = process.env.ORCA_OPEN_COMMAND if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) { @@ -155,7 +162,7 @@ function waitForRecipeJson(child: ReturnType): Promise): Promise { - process.stdout.write(`${line}\n`) - finish() + const writeIgnoredRecipeStdout = (): void => { + // Why: non-readiness child stdout is untrusted and cannot be safely + // redacted, including schema-valid results with arbitrary user data. + process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) } - const onData = (chunk: Buffer | string): void => { - output += chunk.toString() - const newlineIndex = output.indexOf('\n') - if (newlineIndex === -1) { + const processRecipeOutputLine = (line: string): void => { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine.trim()) { return } - emitLine(output.slice(0, newlineIndex)) + const parsed = parseEphemeralVmRecipeResult(normalizedLine) + if (!parsed.ok) { + writeIgnoredRecipeStdout() + return + } + if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { + writeIgnoredRecipeStdout() + return + } + process.stdout.write(`${normalizedLine.trim()}\n`) + finish() + } + const stdoutDecoder = new StringDecoder('utf8') + const onData = (chunk: Buffer | string): void => { + output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk) + while (!settled) { + const newlineIndex = output.indexOf('\n') + if (newlineIndex === -1) { + return + } + const line = output.slice(0, newlineIndex) + output = output.slice(newlineIndex + 1) + processRecipeOutputLine(line) + } } const onError = (error: Error): void => { finish(error) } - const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { if (settled) { return } - const trimmed = output.trim() - if (trimmed) { - emitLine(trimmed) + output += stdoutDecoder.end() + if (output.trim()) { + processRecipeOutputLine(output) + } + if (settled) { return } finish( new RuntimeClientError( 'runtime_serve_failed', typeof code === 'number' - ? `Orca serve exited before printing recipe JSON with code ${code}.` - : `Orca serve exited before printing recipe JSON via ${signal}.` + ? `Orca serve exited before printing valid recipe JSON with code ${code}.` + : `Orca serve exited before printing valid recipe JSON via ${signal}.` ) ) } child.stdout?.on('data', onData) child.once('error', onError) - child.once('exit', onExit) + // Why: `exit` can precede the final piped stdout data. `close` waits until + // stdio closes so a last recipe chunk is not mistaken for missing output. + child.once('close', onClose) }) } diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 3eb022dd7ed..bb63f261d00 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -26,7 +26,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:".', 'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.', - 'A worker_done with the active task/dispatch IDs completes that task when sent from the dispatched pane (or when pane identity is unavailable); on older runtimes the sender must match the dispatch assignee handle, so avoid overriding --from.', + 'A worker_done with the active task/dispatch IDs completes that task only from the dispatched pane. When stable pane identity is unavailable, the sender handle must exactly match the dispatch assignee; injected preambles include the correct --from value.', 'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.' ] }, diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index ba843ad9856..7423be836dd 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -3791,8 +3791,17 @@ describe('Claude hook normalization', () => { expect(result?.payload.toolInput).toBeUndefined() }) - it('PostToolUseFailure surfaces the error text as lastAssistantMessage', () => { - const result = _internals.normalizeHookPayload( + it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/src/config.ts' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( 'claude', buildBody({ hook_event_name: 'PostToolUseFailure', @@ -3802,9 +3811,27 @@ describe('Claude hook normalization', () => { }), 'production' ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.toolName).toBe('Edit') - expect(result?.payload.lastAssistantMessage).toBe('file is read-only') + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'file is read-only' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + + const retry = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/src/config.ts' } + }), + 'production' + ) + expect(retry?.payload).toMatchObject({ + state: 'working', + toolName: 'Read', + toolInput: '/src/config.ts' + }) }) it('PreToolUse normalizes to working + tool fields', () => { @@ -4524,6 +4551,36 @@ describe('Cursor hook normalization', () => { expect(result?.payload.toolInput).toBe('/repo/src/app.ts') }) + it('postToolUseFailure surfaces the error and clears stale tool fields', () => { + _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'preToolUse', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'postToolUseFailure', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' }, + error_message: 'file not found' + }), + 'production' + ) + // Why: keeping toolName set would let the compact sidebar show the tool + // instead of the failure text, hiding the error from the user. + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'file not found' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + it('afterAgentResponse carries text into lastAssistantMessage', () => { const result = _internals.normalizeHookPayload( 'cursor', @@ -5162,6 +5219,36 @@ describe('Copilot hook normalization', () => { expect(result?.payload.toolInput).toBe('pnpm test') }) + it('PostToolUseFailure surfaces the error and clears stale tool fields', () => { + _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PreToolUse', + toolName: 'bash', + toolInput: { command: 'pnpm test' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PostToolUseFailure', + toolName: 'bash', + toolInput: { command: 'pnpm test' }, + error_message: 'command not found' + }), + 'production' + ) + // Why: keeping toolName set would let the compact sidebar show the tool + // instead of the failure text, hiding the error from the user. + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'command not found' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + it('PreToolUse ask_user maps to blocked and surfaces the question', () => { _internals.normalizeHookPayload( 'copilot', diff --git a/src/main/cli/keyed-promise-queue.test.ts b/src/main/cli/keyed-promise-queue.test.ts new file mode 100644 index 00000000000..a4a9aab5687 --- /dev/null +++ b/src/main/cli/keyed-promise-queue.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue' + +describe('runKeyedSerializedOperation', () => { + it('propagates rejections to the caller but never through the stored tail', async () => { + const queues = new Map>() + const failing = runKeyedSerializedOperation(queues, 'key', async () => { + throw new Error('write failed') + }) + // Why: awaiting the tail is how reads barrier on writes; an unrelated + // failed write must not abort the reader (startup candidate discovery). + const tail = getKeyedSerializedQueueTail(queues, 'key') + + await expect(failing).rejects.toThrow('write failed') + await expect(tail).resolves.toBeUndefined() + await expect(runKeyedSerializedOperation(queues, 'key', async () => 'recovered')).resolves.toBe( + 'recovered' + ) + }) + + it('serializes operations per key and clears settled queues', async () => { + const queues = new Map>() + const events: string[] = [] + let release!: () => void + const first = runKeyedSerializedOperation(queues, 'a', async () => { + events.push('first-start') + await new Promise((resolve) => { + release = resolve + }) + events.push('first-end') + }) + const second = runKeyedSerializedOperation(queues, 'a', async () => { + events.push('second') + }) + const other = runKeyedSerializedOperation(queues, 'b', async () => { + events.push('other') + }) + + await other + expect(events).toEqual(['first-start', 'other']) + release() + await Promise.all([first, second]) + expect(events).toEqual(['first-start', 'other', 'first-end', 'second']) + await expect(getKeyedSerializedQueueTail(queues, 'a')).resolves.toBeUndefined() + expect(queues.size).toBe(0) + }) +}) diff --git a/src/main/cli/keyed-promise-queue.ts b/src/main/cli/keyed-promise-queue.ts new file mode 100644 index 00000000000..8fb7b8d56aa --- /dev/null +++ b/src/main/cli/keyed-promise-queue.ts @@ -0,0 +1,33 @@ +/** + * Serializes async operations per key. The stored queue tail never rejects, so + * callers may await it (e.g. read-after-write barriers) without inheriting an + * unrelated operation's failure; rejections still propagate to the enqueuer. + */ +export function runKeyedSerializedOperation( + queues: Map>, + key: string, + operation: () => Promise +): Promise { + const previous = queues.get(key) ?? Promise.resolve() + const current = previous.then(operation) + const queued = current.then( + () => undefined, + () => undefined + ) + queues.set(key, queued) + + const clear = (): void => { + if (queues.get(key) === queued) { + queues.delete(key) + } + } + queued.then(clear, clear) + return current +} + +export function getKeyedSerializedQueueTail( + queues: Map>, + key: string +): Promise { + return queues.get(key) ?? Promise.resolve() +} diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index b9228fcb6a2..6324e75174e 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -1,22 +1,28 @@ import type { CliInstallStatus } from '../../shared/cli-install-types' +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const execFileMock = vi.hoisted(() => vi.fn()) -vi.mock('node:child_process', () => ({ +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal>()), execFile: execFileMock })) import { WslCliInstaller, _internals } from './wsl-cli-installer' +import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation' function makeHostStatus( - launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' ) { return { platform: 'win32', commandName: 'orca', - commandPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd', - pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin', + commandPath: launcherPath, + pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin', pathConfigured: true, launcherPath, installMethod: 'wrapper', @@ -28,13 +34,51 @@ function makeHostStatus( } satisfies CliInstallStatus } -function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) { +// Frozen from v1.4.138-rc.2: the upgrade regression only reproduces when the +// persisted managed script still names the pre-native Windows batch launcher. +const PRE_RC4_MANAGED_WSL_LAUNCHER = `#!/usr/bin/env bash +set -euo pipefail +# Orca managed WSL CLI launcher +# ORCA_WIN_LAUNCHER_B64=QzpcUHJvZ3JhbSBGaWxlc1xPcmNhXHJlc291cmNlc1xiaW5cb3JjYS5jbWQ= +ORCA_WIN_LAUNCHER='C:\\Program Files\\Orca\\resources\\bin\\orca.cmd' +ORCA_BRIDGE_PS1='/home/alice/.local/share/orca/orca-wsl-bridge.ps1' +if command -v powershell.exe >/dev/null 2>&1; then + ORCA_POWERSHELL=powershell.exe +elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then + ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe +else + echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 + exit 1 +fi +ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") +exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" +` + +function createWslRunner( + initialFile: string | null = null, + pathIncludesLocalBin = true, + options: { + initialBridge?: string | null + initialLegacyFile?: string | null + failInstall?: boolean + interopReady?: boolean + } = {} +) { const commandPath = '/home/alice/.local/bin/orca-ide' + const legacyCommandPath = '/home/alice/.local/bin/orca' const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' const files = new Map() if (initialFile !== null) { files.set(commandPath, initialFile) - files.set(bridgePath, _internals.buildWslBridgeScript()) + } + if ( + options.initialBridge !== null && + (initialFile !== null || options.initialBridge !== undefined) + ) { + files.set(bridgePath, options.initialBridge ?? _internals.buildWslBridgeScript()) + } + if (options.initialLegacyFile) { + files.set(legacyCommandPath, options.initialLegacyFile) } const calls: string[] = [] const runner = vi.fn(async (_distro: string, command: string) => { @@ -46,6 +90,15 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin return pathIncludesLocalBin ? 'yes' : 'no' } if (command.includes('cat > "$command_tmp"')) { + if (options.failInstall) { + throw new Error('simulated replacement failure') + } + if ( + files.has(bridgePath) && + !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge') + ) { + throw new Error('__ORCA_CONFLICT__') + } const launcher = command.match(/cat > "\$command_tmp" <<'ORCA_WSL_CLI'\n([\s\S]*)\nORCA_WSL_CLI/)?.[1] ?? '' const bridge = @@ -54,20 +107,31 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin )?.[1] ?? '' files.set(commandPath, launcher) files.set(bridgePath, bridge) + if (files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')) { + files.delete(legacyCommandPath) + } return '' } if (command.includes('command -v powershell.exe')) { - return 'yes' + return options.interopReady === false ? 'no' : 'yes' } if (command.includes('rm -f')) { + if (command.includes(`rm -f '${commandPath}'`)) { + if ( + files.has(bridgePath) && + !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge') + ) { + throw new Error('__ORCA_CONFLICT__') + } + files.delete(commandPath) + files.delete(bridgePath) + } if ( - files.has(bridgePath) && - !files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge') + command.includes(legacyCommandPath) && + files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher') ) { - throw new Error('__ORCA_CONFLICT__') + files.delete(legacyCommandPath) } - files.delete(commandPath) - files.delete(bridgePath) return '' } if (command.includes('cat ')) { @@ -77,6 +141,9 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin if (command.includes(bridgePath)) { return files.get(bridgePath) ?? '__ORCA_MISSING__' } + if (command.includes(legacyCommandPath)) { + return files.get(legacyCommandPath) ?? '__ORCA_MISSING__' + } } throw new Error(`Unexpected WSL command: ${command}`) }) @@ -84,7 +151,8 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin runner, calls, getBridge: () => files.get(bridgePath) ?? null, - getFile: () => files.get(commandPath) ?? null + getFile: () => files.get(commandPath) ?? null, + getLegacyFile: () => files.get(legacyCommandPath) ?? null } } @@ -116,11 +184,11 @@ describe('WslCliInstaller', () => { expect(installed).toMatchObject({ state: 'installed', pathConfigured: true, - launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' }) expect(wsl.getFile()).toBe( _internals.buildWslLauncher( - 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd', + 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe', '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' ) ) @@ -128,6 +196,7 @@ describe('WslCliInstaller', () => { const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'") expect(installCommand).toContain('rm -f "$legacy_command_path"') + expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]') }) it('derives the shared WSL bridge path for current and legacy command names', () => { @@ -291,15 +360,387 @@ describe('WslCliInstaller', () => { await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale', currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd', - launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' }) await expect(installer.install()).resolves.toMatchObject({ state: 'installed', - currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd' + currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe' }) }) + it('repairs the frozen pre-rc4 registration so orchestration send/reply reach native rc4', async () => { + const nativeLauncher = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe' + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: wsl.runner + }) + const orchestrationCalls = [ + ['orchestration', 'send', '--type', 'heartbeat'], + ['orchestration', 'send', '--type', 'worker_done'], + ['orchestration', 'reply', '--message', 'line one\nline two'] + ] + const simulateRc4Launch = (args: string[]): number => { + const target = _internals.parseManagedLauncherTarget(wsl.getFile() ?? '') + return target?.toLowerCase().endsWith('orca.cmd') && + args[0] === 'orchestration' && + (args[1] === 'send' || args[1] === 'reply') + ? 2 + : 0 + } + + expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([2, 2, 2]) + + await expect( + reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu'], + registry: { + getCandidates: async () => ['Ubuntu'], + recordObservations: async () => undefined + }, + createInstaller: () => installer + }) + ).resolves.toEqual([ + { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true } + ]) + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'installed', + currentTarget: nativeLauncher + }) + expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([0, 0, 0]) + }) + + it('leaves unmanaged WSL commands and conflicting bridges untouched during automatic repair', async () => { + const unmanaged = '#!/usr/bin/env bash\necho user-owned\n' + const wsl = createWslRunner(unmanaged) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + status: { state: 'conflict' } + }) + expect(wsl.getFile()).toBe(unmanaged) + expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false) + }) + + it('repairs a managed launcher whose bridge is missing, but preserves a conflicting bridge', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const missingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: null + }) + const missingBridgeInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: missingBridge.runner + }) + + await expect(missingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: true, + status: { state: 'installed' } + }) + expect(missingBridge.getBridge()).toBe(_internals.buildWslBridgeScript()) + + const staleBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: '# Orca managed WSL CLI PowerShell bridge\nWrite-Output "stale"\n' + }) + const staleBridgeInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: staleBridge.runner + }) + await expect(staleBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: true, + status: { state: 'installed' } + }) + expect(staleBridge.getBridge()).toBe(_internals.buildWslBridgeScript()) + + const conflictingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: 'Write-Output "user-owned bridge"\n' + }) + const conflictingBridgeInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: conflictingBridge.runner + }) + + // Why: a stale launcher with a user-owned bridge must surface as a + // non-throwing conflict, not retry a doomed install on every startup. + await expect(conflictingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + managed: true, + status: { state: 'conflict' } + }) + expect(conflictingBridge.getBridge()).toBe('Write-Output "user-owned bridge"\n') + expect(conflictingBridge.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + expect( + conflictingBridge.calls.some((command) => command.includes('cat > "$command_tmp"')) + ).toBe(false) + }) + + it('retains command ownership when only the bridge conflicts', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const currentLauncher = _internals.buildWslLauncher( + nativeLauncher, + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ) + const wsl = createWslRunner(currentLauncher, true, { + initialBridge: 'Write-Output "user-owned bridge"\n' + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + managed: true, + status: { state: 'conflict' } + }) + expect(wsl.getBridge()).toBe('Write-Output "user-owned bridge"\n') + expect(wsl.getFile()).toBe(currentLauncher) + }) + + it('moves a legacy-only managed registration to orca-ide without touching unmanaged names', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const managedLegacy = createWslRunner(null, true, { + initialBridge: _internals.buildWslBridgeScript(), + initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: managedLegacy.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: true, + status: { state: 'installed', currentTarget: nativeLauncher } + }) + expect(managedLegacy.getLegacyFile()).toBeNull() + + const unmanagedLegacy = createWslRunner(null, true, { + initialLegacyFile: '#!/bin/sh\necho user-owned\n' + }) + const unmanagedInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: unmanagedLegacy.runner + }) + await expect(unmanagedInstaller.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + status: { state: 'not_installed' } + }) + expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n') + }) + + it('does not adopt a legacy-managed registration when the bridge is user-owned', async () => { + const wsl = createWslRunner(null, true, { + initialBridge: 'Write-Output "user-owned bridge"\n', + initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: wsl.runner + }) + + // Why: adoption would fail install()'s bridge guard on every startup; + // repair must report blocked-but-managed instead of a doomed install. + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + managed: true, + status: { state: 'not_installed' } + }) + expect(wsl.getLegacyFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false) + }) + + it('removes the managed legacy launcher on removal so reconciliation cannot re-adopt it', async () => { + const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe' + const managedLegacy = createWslRunner(null, true, { + initialBridge: _internals.buildWslBridgeScript(), + initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER + }) + const managedLegacyInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: managedLegacy.runner + }) + await expect(managedLegacyInstaller.remove()).resolves.toMatchObject({ + state: 'not_installed' + }) + expect(managedLegacy.getLegacyFile()).toBeNull() + + const unmanagedLegacy = createWslRunner(null, true, { + initialLegacyFile: '#!/bin/sh\necho user-owned\n' + }) + const unmanagedInstaller2 = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: unmanagedLegacy.runner + }) + await expect(unmanagedInstaller2.remove()).resolves.toMatchObject({ + state: 'not_installed' + }) + expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n') + + const installedWithLegacy = createWslRunner( + _internals.buildWslLauncher( + nativeLauncher, + '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' + ), + true, + { initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER } + ) + const installedInstaller = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: installedWithLegacy.runner + }) + await expect(installedInstaller.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(installedWithLegacy.getFile()).toBeNull() + expect(installedWithLegacy.getLegacyFile()).toBeNull() + }) + + it('keeps the pre-rc4 files on a transactional replacement failure', async () => { + const bridge = _internals.buildWslBridgeScript() + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { + initialBridge: bridge, + failInstall: true + }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { + getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe') + }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).rejects.toThrow( + 'simulated replacement failure' + ) + expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + expect(wsl.getBridge()).toBe(bridge) + const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toContain('rollback() {') + expect(installCommand).toContain('set +e') + expect(installCommand).toContain('bridge_backup="${bridge_tmp}.backup"') + expect(installCommand).toContain('cp -p') + expect(installCommand).toContain('elif [ "$bridge_touched" -eq 1 ]') + expect(installCommand).toContain('committed=1') + expect(installCommand).toContain('flock -x -w 30 9') + // Why: the command replace must stay one atomic rename; a mv-based backup + // would leave a window where a concurrent shell finds no orca-ide at all. + expect(installCommand).not.toContain('command_backup') + expect(installCommand).not.toContain(`mv -f '/home/alice/.local/bin/orca-ide'`) + }) + + it.skipIf(process.platform === 'win32')( + 'rolls both files back when the command replacement fails after the bridge move', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-rollback-')) + const home = join(root, 'home with spaces') + const commandPath = join(home, '.local', 'bin', 'orca-ide') + const bridgePath = join(home, '.local', 'share', 'orca', 'orca-wsl-bridge.ps1') + const bridge = _internals.buildWslBridgeScript() + await mkdir(join(home, '.local', 'bin'), { recursive: true }) + await mkdir(join(home, '.local', 'share', 'orca'), { recursive: true }) + await writeFile(commandPath, PRE_RC4_MANAGED_WSL_LAUNCHER, 'utf8') + await writeFile(bridgePath, bridge, 'utf8') + + const runner = async (_distro: string, command: string): Promise => { + if (command.includes('printf %s "$HOME"')) { + return home + } + if (command.includes('cat > "$command_tmp"')) { + const executableCommand = command + .split('\n') + .map((line) => (line.startsWith('mv -f "$command_tmp" ') ? 'exit 71' : line)) + .join('\n') + return execFileSync('bash', ['-c', executableCommand], { encoding: 'utf8' }) + } + if (command.includes('command -v powershell.exe')) { + return 'yes' + } + if (command.includes('case ":$PATH:"')) { + return 'yes' + } + return execFileSync('bash', ['-c', command], { encoding: 'utf8' }) + } + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { + getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe') + }, + wslRunner: runner + }) + + try { + await expect(installer.repairManagedRegistration()).rejects.toThrow() + await expect(readFile(commandPath, 'utf8')).resolves.toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + await expect(readFile(bridgePath, 'utf8')).resolves.toBe(bridge) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) + + it('skips automatic repair when WSL interop is unavailable', async () => { + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { interopReady: false }) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus() }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ + changed: false, + status: { state: 'unsupported' } + }) + expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER) + }) + + it('is idempotent after repairing an old managed registration', async () => { + const nativeLauncher = 'D:\\Custom Orca\\resources\\bin\\orca.exe' + const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER) + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) }, + wslRunner: wsl.runner + }) + + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: true }) + await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: false }) + expect(wsl.calls.filter((command) => command.includes('cat > "$command_tmp"'))).toHaveLength(1) + expect(wsl.getFile()).toContain("ORCA_WIN_LAUNCHER='D:\\Custom Orca\\resources\\bin\\orca.exe'") + }) + it('settles when wsl.exe never reports completion', async () => { vi.useFakeTimers() const killMock = vi.fn() diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 5d473ad5bfd..f6a1684e19c 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -5,6 +5,8 @@ import type { CliInstallStatus } from '../../shared/cli-install-types' import { getDefaultWslDistro } from '../wsl' import { CliInstaller } from './cli-installer' import { + buildManagedLegacyRemoveCommand, + buildRegistrationLockPrelude, buildSafeRemoveCommand, buildSafeReplaceGuard, buildWslBridgeScript, @@ -38,6 +40,12 @@ type WslCliInstallerOptions = { wslRunner?: (distro: string, command: string) => Promise } +export type ManagedWslCliRepairResult = { + changed: boolean + managed: boolean + status: CliInstallStatus +} + export class WslCliInstaller { private readonly platform: NodeJS.Platform private readonly distro: string | null @@ -119,21 +127,80 @@ export class WslCliInstaller { }) } + // Why: a stale managed launcher is only repairable when its bridge is + // ours too; reporting conflict here keeps repair from a doomed install + // whose bridge guard would fail on every startup. + const bridgeConflict = managed && (await this.isBridgeConflict(ready.distro, ready.bridgePath)) return this.buildStatus({ distro: ready.distro, commandPath: ready.commandPath, launcherPath: ready.launcherPath, - state: managed ? 'stale' : 'conflict', + state: managed && !bridgeConflict ? 'stale' : 'conflict', currentTarget, pathConfigured: ready.pathConfigured, - detail: managed - ? `${ready.commandPath} points to a different Orca launcher.` - : `${ready.commandPath} exists but is not managed by Orca.` + detail: !managed + ? `${ready.commandPath} exists but is not managed by Orca.` + : bridgeConflict + ? `${ready.bridgePath} exists but is not managed by Orca.` + : `${ready.commandPath} points to a different Orca launcher.` }) } - async install(): Promise { + private async isBridgeConflict(distro: string, bridgePath: string): Promise { + const bridgeContent = await this.readCommandFile(distro, bridgePath) + if (bridgeContent === null) { + return false + } + return bridgeContent === 'not_file' || !bridgeContent.includes(BRIDGE_MANAGED_MARKER) + } + + async repairManagedRegistration(): Promise { const status = await this.getStatus() + if (!status.supported) { + return { changed: false, managed: false, status } + } + if (status.state === 'conflict') { + // Why: a user-owned bridge conflicts with repair, but the launcher is + // still Orca-managed and must remain registered for future reconciliation. + return { changed: false, managed: status.currentTarget !== null, status } + } + + if (status.state === 'stale') { + return { changed: true, managed: true, status: await this.install(status) } + } + + const legacyCommandPath = status.commandPath + ? `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` + : null + if (!legacyCommandPath || !this.distro) { + return { changed: false, managed: status.state === 'installed', status } + } + + const legacyContent = await this.readCommandFile(this.distro, legacyCommandPath) + const legacyManaged = + typeof legacyContent === 'string' && legacyContent.includes(MANAGED_MARKER) + if (!legacyManaged) { + return { changed: false, managed: status.state === 'installed', status } + } + + if ( + status.commandPath && + (await this.isBridgeConflict(this.distro, getBridgePathFromCommandPath(status.commandPath))) + ) { + // Why: adopting the legacy command would fail install()'s bridge guard + // forever; stay registered so reconciliation retries after an update. + return { changed: false, managed: true, status } + } + + // Why: a legacy-only managed command proves the user opted into WSL CLI + // registration; install the current name before removing that owned script. + return { changed: true, managed: true, status: await this.install(status) } + } + + async install(precomputedStatus?: CliInstallStatus): Promise { + // Why: repair passes its fresh probe; re-probing here would double every + // WSL round trip on the startup reconciliation path. + const status = precomputedStatus ?? (await this.getStatus()) if (!status.supported || !status.commandPath || !status.launcherPath) { throw new Error(status.detail ?? 'WSL CLI registration is unavailable.') } @@ -141,20 +208,36 @@ export class WslCliInstaller { throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) } + // Why: the launcher and PowerShell bridge are one registration; the + // command replacement stays a single atomic rename (never missing for a + // concurrent shell) while a bridge copy enables rollback of the pair. await this.run( this.distro as string, [ 'set -euo pipefail', `mkdir -p ${quoteShell(status.pathDirectory as string)}`, `mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`, + buildRegistrationLockPrelude(status.commandPath), `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, `bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, `legacy_command_path=${quoteShell( `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` )}`, 'bridge_tmp="${bridge_path}.tmp.$$"', - 'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }', - 'trap cleanup EXIT', + 'bridge_backup="${bridge_tmp}.backup"', + 'bridge_had_original=0', + 'bridge_touched=0', + 'committed=0', + 'rollback() {', + ' result=$?', + ' set +e', + ' if [ "$committed" -ne 1 ]; then', + ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`, + ' fi', + ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"', + ' exit "$result"', + '}', + 'trap rollback EXIT', buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), buildSafeReplaceGuard( getBridgePathFromCommandPath(status.commandPath), @@ -173,11 +256,15 @@ export class WslCliInstaller { getBridgePathFromCommandPath(status.commandPath), BRIDGE_MANAGED_MARKER ), - // Why: the command was renamed to avoid GNOME Orca; remove only the - // old Orca-managed WSL wrapper so unmanaged `orca` commands survive. - `if [ -f "$legacy_command_path" ] && grep -Fq ${quoteShell(MANAGED_MARKER)} "$legacy_command_path"; then rm -f "$legacy_command_path"; fi`, + `if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`, `mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, + 'bridge_touched=1', `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, + 'committed=1', + 'rm -f "$bridge_backup"', + // Why: the command was renamed to avoid GNOME Orca; remove only the + // old Orca-managed WSL wrapper after the replacement has committed. + buildManagedLegacyRemoveCommand('"$legacy_command_path"'), 'trap - EXIT' ].join('\n') ) @@ -189,14 +276,26 @@ export class WslCliInstaller { if (!status.supported || !status.commandPath) { return status } + const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` if (status.state === 'not_installed') { + // Why: a managed legacy `orca` left behind would later be re-adopted by + // startup reconciliation as opt-in proof, silently undoing this removal. + await this.run( + this.distro as string, + ['set -euo pipefail', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join( + '\n' + ) + ) return status } if (status.state === 'conflict') { throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`) } - await this.run(this.distro as string, buildSafeRemoveCommand(status.commandPath)) + await this.run( + this.distro as string, + buildSafeRemoveCommand(status.commandPath, legacyCommandPath) + ) return this.getStatus() } @@ -411,5 +510,6 @@ export const _internals = { buildEncodedWslBashCommand, buildWslBridgeScript, buildWslLauncher, - getBridgePathFromCommandPath + getBridgePathFromCommandPath, + parseManagedLauncherTarget } diff --git a/src/main/cli/wsl-cli-registration-operation.test.ts b/src/main/cli/wsl-cli-registration-operation.test.ts new file mode 100644 index 00000000000..0240690ffb8 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-operation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation' + +describe('runSerializedWslCliRegistrationOperation', () => { + it('serializes operations for distro names with different casing', async () => { + let releaseFirst!: () => void + const events: string[] = [] + const first = runSerializedWslCliRegistrationOperation('Ubuntu', async () => { + events.push('first-start') + await new Promise((resolve) => { + releaseFirst = resolve + }) + events.push('first-end') + }) + + await vi.waitFor(() => expect(events).toEqual(['first-start'])) + const second = runSerializedWslCliRegistrationOperation(' ubuntu ', async () => { + events.push('second') + }) + await Promise.resolve() + expect(events).toEqual(['first-start']) + + releaseFirst() + await Promise.all([first, second]) + expect(events).toEqual(['first-start', 'first-end', 'second']) + }) + + it('allows different distros to progress independently', async () => { + let releaseUbuntu!: () => void + const events: string[] = [] + const ubuntu = runSerializedWslCliRegistrationOperation('Ubuntu', async () => { + events.push('ubuntu-start') + await new Promise((resolve) => { + releaseUbuntu = resolve + }) + }) + const debian = runSerializedWslCliRegistrationOperation('Debian', async () => { + events.push('debian') + }) + + await debian + expect(events).toEqual(['ubuntu-start', 'debian']) + releaseUbuntu() + await ubuntu + }) + + it('releases the queue after an operation fails', async () => { + await expect( + runSerializedWslCliRegistrationOperation('Ubuntu', async () => { + throw new Error('interop failed') + }) + ).rejects.toThrow('interop failed') + + await expect( + runSerializedWslCliRegistrationOperation('ubuntu', async () => 'recovered') + ).resolves.toBe('recovered') + }) +}) diff --git a/src/main/cli/wsl-cli-registration-operation.ts b/src/main/cli/wsl-cli-registration-operation.ts new file mode 100644 index 00000000000..2ed8404774c --- /dev/null +++ b/src/main/cli/wsl-cli-registration-operation.ts @@ -0,0 +1,23 @@ +import { runKeyedSerializedOperation } from './keyed-promise-queue' + +const operationQueues = new Map>() + +/** + * Canonical key for a WSL distro name. The operation queue and the + * registration registry must agree on this to serialize against each other. + */ +export function normalizeWslDistroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +/** + * Serializes registration reads and mutations for one WSL distro. + */ +export function runSerializedWslCliRegistrationOperation( + distro: string, + operation: () => Promise +): Promise { + // Why: startup repair continues in the background and can otherwise undo a + // concurrent Settings install/remove or overwrite its ownership metadata. + return runKeyedSerializedOperation(operationQueues, normalizeWslDistroKey(distro), operation) +} diff --git a/src/main/cli/wsl-cli-registration-reconciliation.test.ts b/src/main/cli/wsl-cli-registration-reconciliation.test.ts new file mode 100644 index 00000000000..48b75dd207d --- /dev/null +++ b/src/main/cli/wsl-cli-registration-reconciliation.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest' +import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation' +import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation' + +describe('reconcileManagedWslCliRegistrations', () => { + it('repairs known and newly discovered registrations, then records ownership', async () => { + const registry = { + getCandidates: vi.fn(async () => ['Ubuntu', 'Debian']), + recordObservations: vi.fn(async () => undefined) + } + const repairUbuntu = vi.fn(async () => ({ + changed: true, + managed: true, + status: { state: 'installed' as const } + })) + const repairDebian = vi.fn(async () => ({ + changed: false, + managed: false, + status: { state: 'not_installed' as const } + })) + + const results = await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu', 'Debian', 'Fedora'], + registry, + createInstaller: (distro) => { + if (distro === 'Ubuntu') { + return { repairManagedRegistration: repairUbuntu } + } + return { repairManagedRegistration: repairDebian } + } + }) + + expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu', 'Debian', 'Fedora'], { + currentTarget: null, + appVersion: '' + }) + expect(registry.recordObservations).toHaveBeenCalledTimes(2) + expect(registry.recordObservations).toHaveBeenCalledWith([ + { distro: 'Ubuntu', inspected: true, managed: true } + ]) + expect(registry.recordObservations).toHaveBeenCalledWith([ + { distro: 'Debian', inspected: true, managed: false, reconciled: null } + ]) + expect(results).toEqual([ + { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }, + { distro: 'Debian', outcome: 'unchanged', state: 'not_installed', managed: false } + ]) + }) + + it('passes the host launcher target through and records reconciliations against it', async () => { + const registry = { + getCandidates: vi.fn(async () => ['Ubuntu']), + recordObservations: vi.fn(async () => undefined) + } + + await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + appVersion: '1.4.138', + listDistros: async () => ['Ubuntu'], + getHostLauncherTarget: async () => 'C:\\Orca\\resources\\bin\\orca.exe', + registry, + createInstaller: () => ({ + repairManagedRegistration: async () => ({ + changed: true, + managed: true, + status: { state: 'installed' as const } + }) + }) + }) + + expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu'], { + currentTarget: 'C:\\Orca\\resources\\bin\\orca.exe', + appVersion: '1.4.138' + }) + expect(registry.recordObservations).toHaveBeenCalledWith([ + { + distro: 'Ubuntu', + inspected: true, + managed: true, + reconciled: { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' } + } + ]) + }) + + it('records unsupported distros without changing ownership and skips failed ones', async () => { + const registry = { + getCandidates: vi.fn(async () => ['Broken Distro', 'No Interop']), + recordObservations: vi.fn(async () => undefined) + } + + const results = await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Broken Distro', 'No Interop'], + registry, + createInstaller: (distro) => ({ + repairManagedRegistration: async () => { + if (distro === 'Broken Distro') { + throw new Error('WSL interop failed') + } + return { + changed: false, + managed: false, + status: { state: 'unsupported' as const } + } + } + }) + }) + + expect(results).toEqual([ + { distro: 'Broken Distro', outcome: 'failed', error: 'WSL interop failed' }, + { distro: 'No Interop', outcome: 'unchanged', state: 'unsupported', managed: false } + ]) + expect(registry.recordObservations).toHaveBeenCalledTimes(1) + // Why: unsupported gets a TTL-stamped inspection (managed: null) so an + // interop-off distro is not re-probed and VM-booted on every startup. + expect(registry.recordObservations).toHaveBeenCalledWith([ + { distro: 'No Interop', inspected: true, managed: null, reconciled: null } + ]) + }) + + it('keeps a successful repair result when observation recording fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const results = await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu'], + registry: { + getCandidates: async () => ['Ubuntu'], + recordObservations: async () => { + throw new Error('ENOSPC') + } + }, + createInstaller: () => ({ + repairManagedRegistration: async () => ({ + changed: true, + managed: true, + status: { state: 'installed' as const } + }) + }) + }) + + expect(results).toEqual([ + { distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true } + ]) + expect(warn).toHaveBeenCalledOnce() + } finally { + warn.mockRestore() + } + }) + + it.each([ + { platform: 'darwin' as const, isPackaged: true }, + { platform: 'linux' as const, isPackaged: true }, + { platform: 'win32' as const, isPackaged: false } + ])('does not inspect local, SSH, or development hosts for $platform', async (host) => { + const listDistros = vi.fn(async () => ['Ubuntu']) + + await expect( + reconcileManagedWslCliRegistrations({ + ...host, + userDataPath: '/user-data', + listDistros + }) + ).resolves.toEqual([]) + expect(listDistros).not.toHaveBeenCalled() + }) + + it('lets a Settings removal win over a late startup repair for the same distro', async () => { + const events: string[] = [] + let repairStarted!: () => void + let finishRepair!: () => void + const started = new Promise((resolve) => { + repairStarted = resolve + }) + const registry = { + getCandidates: vi.fn(async () => ['Ubuntu']), + recordObservations: vi.fn(async () => { + events.push('repair-observed') + }) + } + const reconciliation = reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu'], + registry, + createInstaller: () => ({ + repairManagedRegistration: async () => { + events.push('repair-started') + repairStarted() + await new Promise((resolve) => { + finishRepair = resolve + }) + events.push('repair-finished') + return { + changed: true, + managed: true, + status: { state: 'installed' as const } + } + } + }) + }) + await started + + const removal = runSerializedWslCliRegistrationOperation('ubuntu', async () => { + events.push('settings-remove') + }) + await Promise.resolve() + expect(events).toEqual(['repair-started']) + + finishRepair() + await Promise.all([reconciliation, removal]) + expect(events).toEqual([ + 'repair-started', + 'repair-finished', + 'repair-observed', + 'settings-remove' + ]) + }) +}) diff --git a/src/main/cli/wsl-cli-registration-reconciliation.ts b/src/main/cli/wsl-cli-registration-reconciliation.ts new file mode 100644 index 00000000000..b4c4933bcd1 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-reconciliation.ts @@ -0,0 +1,175 @@ +import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types' +import { listWslDistrosAsync } from '../wsl' +import { CliInstaller } from './cli-installer' +import { + getWslCliRegistrationCandidates, + recordWslCliRegistrationObservations, + type WslCliRegistrationObservation +} from './wsl-cli-registration-registry' +import { WslCliInstaller } from './wsl-cli-installer' +import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation' + +// Why: candidate distros can each boot a stopped WSL VM; a small cap staggers +// those boots instead of spiking RAM/CPU for every distro at once at startup. +const MAX_CONCURRENT_DISTRO_REPAIRS = 2 + +type ManagedWslCliInstaller = { + repairManagedRegistration: () => Promise<{ + changed: boolean + managed: boolean + status: { state: CliInstallState } + }> +} + +type WslCliRegistrationCandidateContext = { + currentTarget?: string | null + appVersion?: string | null +} + +type WslCliRegistrationRegistry = { + getCandidates: ( + availableDistros: string[], + context?: WslCliRegistrationCandidateContext + ) => Promise + recordObservations: (observations: WslCliRegistrationObservation[]) => Promise +} + +type WslCliRegistrationReconciliationOptions = { + platform?: NodeJS.Platform + isPackaged: boolean + userDataPath: string + appVersion?: string + listDistros?: () => Promise + createInstaller?: (distro: string) => ManagedWslCliInstaller + getHostLauncherTarget?: () => Promise + registry?: WslCliRegistrationRegistry +} + +export type WslCliRegistrationReconciliationResult = + | { + distro: string + outcome: 'repaired' | 'unchanged' + state: CliInstallState + managed: boolean + } + | { + distro: string + outcome: 'failed' + error: string + } + +export async function reconcileManagedWslCliRegistrations( + options: WslCliRegistrationReconciliationOptions +): Promise { + const platform = options.platform ?? process.platform + if (platform !== 'win32' || !options.isPackaged) { + return [] + } + + const registry = + options.registry ?? + ({ + getCandidates: (availableDistros, context) => + getWslCliRegistrationCandidates(options.userDataPath, availableDistros, context ?? {}), + recordObservations: (observations) => + recordWslCliRegistrationObservations(options.userDataPath, observations) + } satisfies WslCliRegistrationRegistry) + + let createInstaller = options.createInstaller + let getHostLauncherTarget = options.getHostLauncherTarget + if (!createInstaller) { + const hostInstaller = new CliInstaller() + let hostStatus: Promise | null = null + // Why: every distro must target this app install; share one Windows PATH / + // launcher probe instead of spawning a PowerShell probe per distro. A + // rejected probe is evicted so one transient failure cannot poison the run. + const getHostStatus = (): Promise => + (hostStatus ??= hostInstaller.getStatus().catch((error) => { + hostStatus = null + throw error + })) + createInstaller = (distro: string) => + new WslCliInstaller({ + distro, + hostInstaller: { getStatus: getHostStatus } + }) + getHostLauncherTarget ??= () => getHostStatus().then((status) => status.launcherPath) + } + + const availableDistros = await (options.listDistros ?? listWslDistrosAsync)() + const currentTarget = getHostLauncherTarget + ? await getHostLauncherTarget().catch(() => null) + : null + const appVersion = options.appVersion ?? '' + const distros = await registry.getCandidates(availableDistros, { currentTarget, appVersion }) + if (distros.length === 0) { + return [] + } + + const reconcileDistro = async ( + distro: string + ): Promise => { + let repair: Awaited> + try { + repair = await createInstaller(distro).repairManagedRegistration() + } catch (error) { + return { + distro, + outcome: 'failed', + error: error instanceof Error ? error.message : String(error) + } + } + const result: WslCliRegistrationReconciliationResult = { + distro, + outcome: repair.changed ? 'repaired' : 'unchanged', + state: repair.status.state, + managed: repair.managed + } + const observation: WslCliRegistrationObservation = + repair.status.state === 'unsupported' + ? // Why: managed-ness is unknowable without interop; stamp the + // inspection (negative TTL) without changing registration ownership. + { distro, inspected: true, managed: null, reconciled: null } + : repair.managed + ? { + distro, + inspected: true, + managed: true, + ...(currentTarget ? { reconciled: { target: currentTarget, appVersion } } : {}) + } + : { distro, inspected: true, managed: false, reconciled: null } + try { + // Why: ownership metadata must commit before a concurrent Settings + // operation can mutate this distro, or stale startup state can win. + await registry.recordObservations([observation]) + } catch (error) { + // Why: the repair already succeeded on disk; an advisory bookkeeping + // failure must not reclassify it, mirroring the Settings IPC contract. + console.warn( + `[wsl-cli] Failed to record ${distro} registration observation:`, + error instanceof Error ? error.message : String(error) + ) + } + return result + } + + const results: WslCliRegistrationReconciliationResult[] = Array.from({ + length: distros.length + }) + let nextIndex = 0 + await Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENT_DISTRO_REPAIRS, distros.length) }, async () => { + for (;;) { + const index = nextIndex++ + if (index >= distros.length) { + return + } + const distro = distros[index] + results[index] = await runSerializedWslCliRegistrationOperation(distro, () => + reconcileDistro(distro) + ) + } + }) + ) + return results +} diff --git a/src/main/cli/wsl-cli-registration-registry.test.ts b/src/main/cli/wsl-cli-registration-registry.test.ts new file mode 100644 index 00000000000..95bf554a104 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-registry.test.ts @@ -0,0 +1,233 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + getWslCliRegistrationCandidates, + recordWslCliRegistrationObservations, + recordWslCliRegistrationRemoved +} from './wsl-cli-registration-registry' + +describe('WSL CLI registration registry', () => { + let userDataPath: string + + beforeEach(async () => { + userDataPath = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-registry-')) + }) + + afterEach(async () => { + await rm(userDataPath, { recursive: true, force: true }) + }) + + it('discovers each distro once while continuing to reconcile managed registrations', async () => { + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian']) + ).resolves.toEqual(['Ubuntu', 'Debian']) + + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: false }, + { distro: 'Debian', inspected: true, managed: true } + ]) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['ubuntu', 'Debian', 'Fedora']) + ).resolves.toEqual(['Debian', 'Fedora']) + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as Record + expect(state).toMatchObject({ + schemaVersion: 2, + registeredDistros: ['Debian'], + inspectionTimes: { + ubuntu: expect.any(Number), + debian: expect.any(Number) + } + }) + }) + + it('skips a registered distro already reconciled by this build against this launcher', async () => { + const reconciled = { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' } + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true, reconciled } + ]) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: reconciled.target, + appVersion: reconciled.appVersion + }) + ).resolves.toEqual([]) + // A launcher move or app update re-probes the registered distro. + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: 'D:\\Elsewhere\\orca.exe', + appVersion: reconciled.appVersion + }) + ).resolves.toEqual(['Ubuntu']) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: reconciled.target, + appVersion: '1.4.139' + }) + ).resolves.toEqual(['Ubuntu']) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + currentTarget: reconciled.target, + appVersion: reconciled.appVersion, + now: Date.now() + 365 * 24 * 60 * 60 * 1_000 + }) + ).resolves.toEqual([]) + }) + + it('records unsupported inspections without changing registration ownership', async () => { + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true } + ]) + + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Ubuntu', inspected: true, managed: null, reconciled: null }], + { now: 1_000 } + ) + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as { registeredDistros: string[] } + expect(state.registeredDistros).toEqual(['Ubuntu']) + + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Fedora', inspected: true, managed: null }], + { now: 1_000 } + ) + // Unregistered unsupported distros gain the negative-inspection TTL. + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Fedora'], { + now: 2_000, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual([]) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Fedora'], { + now: 12_000, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual(['Fedora']) + }) + + it('serializes concurrent registry updates without losing a distro', async () => { + const updates = Promise.all([ + recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true } + ]), + recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Debian', inspected: true, managed: true } + ]) + ]) + + // Reads join the write queue, so startup cannot observe a half-updated registry. + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian']) + ).resolves.toEqual(['Ubuntu', 'Debian']) + await updates + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as { registeredDistros: string[] } + expect(state.registeredDistros).toEqual(['Ubuntu', 'Debian']) + }) + + it('rediscovers available distros when the registry is corrupt', async () => { + await mkdir(userDataPath, { recursive: true }) + await writeFile(join(userDataPath, 'wsl-cli-registrations.json'), '{broken', 'utf8') + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian']) + ).resolves.toEqual(['Ubuntu', 'Debian']) + }) + + it('stops reconciling a registration removed through Settings', async () => { + await recordWslCliRegistrationObservations(userDataPath, [ + { distro: 'Ubuntu', inspected: true, managed: true } + ]) + await recordWslCliRegistrationRemoved(userDataPath, 'ubuntu') + + await expect(getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'])).resolves.toEqual([]) + }) + + it('periodically re-inspects a negative entry so restored distros are discovered', async () => { + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Ubuntu', inspected: true, managed: false }], + { now: 1_000 } + ) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + now: 1_001, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual([]) + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + now: 11_001, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual(['Ubuntu']) + }) + + it('rediscovers negative entries after the system clock moves backward', async () => { + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Ubuntu', inspected: true, managed: false }], + { now: 100_000 } + ) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], { + now: 1_000, + negativeInspectionTtlMs: 10_000 + }) + ).resolves.toEqual(['Ubuntu']) + }) + + it('safely rediscovers schema-v1 negative entries with no inspection time', async () => { + await writeFile( + join(userDataPath, 'wsl-cli-registrations.json'), + JSON.stringify({ + schemaVersion: 1, + registeredDistros: ['Debian'], + inspectedDistros: ['Ubuntu', 'Debian'] + }), + 'utf8' + ) + + await expect( + getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'], { + now: 1, + negativeInspectionTtlMs: 1_000_000 + }) + ).resolves.toEqual(['Ubuntu', 'Debian']) + }) + + it('caps inspection bookkeeping while always keeping registered distros', async () => { + const observations = Array.from({ length: 70 }, (_, index) => ({ + distro: `Distro${index}`, + inspected: true, + managed: false as const + })) + for (const [index, observation] of observations.entries()) { + await recordWslCliRegistrationObservations(userDataPath, [observation], { now: index }) + } + await recordWslCliRegistrationObservations( + userDataPath, + [{ distro: 'Managed Oldest', inspected: true, managed: true }], + { now: 0 } + ) + + const state = JSON.parse( + await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8') + ) as { registeredDistros: string[]; inspectionTimes: Record } + expect(state.registeredDistros).toEqual(['Managed Oldest']) + expect(Object.keys(state.inspectionTimes).length).toBeLessThanOrEqual(65) + expect(state.inspectionTimes['managed oldest']).toBe(0) + }) +}) diff --git a/src/main/cli/wsl-cli-registration-registry.ts b/src/main/cli/wsl-cli-registration-registry.ts new file mode 100644 index 00000000000..d1a84a42f52 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-registry.ts @@ -0,0 +1,279 @@ +import { mkdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue' +import { normalizeWslDistroKey } from './wsl-cli-registration-operation' + +const REGISTRY_FILE_NAME = 'wsl-cli-registrations.json' +const REGISTRY_SCHEMA_VERSION = 2 +const DEFAULT_NEGATIVE_INSPECTION_TTL_MS = 7 * 24 * 60 * 60 * 1_000 +// Why: the registry is advisory; cap per-distro bookkeeping so hosts that +// cycle many uniquely named distros cannot grow the file without bound. +const MAX_INSPECTION_ENTRIES = 64 + +type WslCliRegistrationReconciliation = { + target: string + appVersion: string +} + +type WslCliRegistrationRegistryState = { + schemaVersion: 2 + registeredDistros: string[] + inspectionTimes: Record + reconciliations: Record +} + +type WslCliRegistrationRegistryTiming = { + now?: number + negativeInspectionTtlMs?: number + // Why: a registered distro already reconciled against this exact launcher + // by this exact app build has nothing to repair; skipping it avoids booting + // its VM on every startup while still re-probing after each app update. + currentTarget?: string | null + appVersion?: string | null +} + +export type WslCliRegistrationObservation = { + distro: string + inspected: boolean + // Why: null records an inspection without changing registration ownership — + // used for 'unsupported' probes where managed-ness could not be determined. + managed: boolean | null + reconciled?: WslCliRegistrationReconciliation | null +} + +const writeQueues = new Map>() + +function emptyState(): WslCliRegistrationRegistryState { + return { + schemaVersion: REGISTRY_SCHEMA_VERSION, + registeredDistros: [], + inspectionTimes: {}, + reconciliations: {} + } +} + +function uniqueDistros(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + const seen = new Set() + const distros: string[] = [] + for (const entry of value) { + if (typeof entry !== 'string' || !entry.trim()) { + continue + } + const distro = entry.trim() + const key = normalizeWslDistroKey(distro) + if (!seen.has(key)) { + seen.add(key) + distros.push(distro) + } + } + return distros +} + +function parseReconciliations(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {} + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, WslCliRegistrationReconciliation] => + !!entry[1] && + typeof entry[1] === 'object' && + typeof (entry[1] as WslCliRegistrationReconciliation).target === 'string' && + typeof (entry[1] as WslCliRegistrationReconciliation).appVersion === 'string' + ) + ) +} + +function parseState(content: string): WslCliRegistrationRegistryState { + try { + const parsed = JSON.parse(content) as Record + if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== REGISTRY_SCHEMA_VERSION) { + return emptyState() + } + const inspectionTimes = + parsed.inspectionTimes && typeof parsed.inspectionTimes === 'object' + ? Object.fromEntries( + Object.entries(parsed.inspectionTimes).filter( + (entry): entry is [string, number] => + typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0 + ) + ) + : {} + return { + schemaVersion: REGISTRY_SCHEMA_VERSION, + registeredDistros: uniqueDistros(parsed.registeredDistros), + inspectionTimes, + reconciliations: parseReconciliations(parsed.reconciliations) + } + } catch { + // Why: a corrupt advisory registry must trigger safe rediscovery rather + // than preventing managed registrations from receiving future updates. + return emptyState() + } +} + +function isMissingError(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT' +} + +function getRegistryPath(userDataPath: string): string { + return join(userDataPath, REGISTRY_FILE_NAME) +} + +async function readState(userDataPath: string): Promise { + try { + return parseState(await readFile(getRegistryPath(userDataPath), 'utf8')) + } catch (error) { + if (isMissingError(error)) { + return emptyState() + } + throw error + } +} + +function upsertDistro(distros: string[], distro: string): string[] { + const key = normalizeWslDistroKey(distro) + const existingIndex = distros.findIndex((entry) => normalizeWslDistroKey(entry) === key) + if (existingIndex < 0) { + return [...distros, distro.trim()] + } + return distros.map((entry, index) => (index === existingIndex ? distro.trim() : entry)) +} + +function removeDistro(distros: string[], distro: string): string[] { + const key = normalizeWslDistroKey(distro) + return distros.filter((entry) => normalizeWslDistroKey(entry) !== key) +} + +async function writeState( + userDataPath: string, + state: WslCliRegistrationRegistryState +): Promise { + await mkdir(userDataPath, { recursive: true }) + // Why: userData writes on Windows can hit Chromium's Protected-DACL EPERM; + // writeFileAtomically carries the ACL-repair retry a plain rename lacks. + writeFileAtomically(getRegistryPath(userDataPath), `${JSON.stringify(state, null, 2)}\n`) +} + +function capInspectionEntries( + state: WslCliRegistrationRegistryState +): WslCliRegistrationRegistryState { + const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey)) + const entries = Object.entries(state.inspectionTimes) + const inspectionTimes = + entries.length <= MAX_INSPECTION_ENTRIES + ? state.inspectionTimes + : Object.fromEntries( + entries + .sort((a, b) => b[1] - a[1]) + .filter((entry, index) => index < MAX_INSPECTION_ENTRIES || registered.has(entry[0])) + ) + const reconciliations = Object.fromEntries( + Object.entries(state.reconciliations).filter(([key]) => registered.has(key)) + ) + return { ...state, inspectionTimes, reconciliations } +} + +function updateState( + userDataPath: string, + update: (state: WslCliRegistrationRegistryState) => WslCliRegistrationRegistryState +): Promise { + return runKeyedSerializedOperation(writeQueues, getRegistryPath(userDataPath), async () => { + await writeState(userDataPath, capInspectionEntries(update(await readState(userDataPath)))) + }) +} + +export async function getWslCliRegistrationCandidates( + userDataPath: string, + availableDistros: string[], + timing: WslCliRegistrationRegistryTiming = {} +): Promise { + // Why: the stored queue tail never rejects, so a failed concurrent write + // cannot abort candidate discovery; reads still see fully applied updates. + await getKeyedSerializedQueueTail(writeQueues, getRegistryPath(userDataPath)) + const state = await readState(userDataPath) + const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey)) + const now = timing.now ?? Date.now() + const negativeInspectionTtlMs = + timing.negativeInspectionTtlMs ?? DEFAULT_NEGATIVE_INSPECTION_TTL_MS + return uniqueDistros(availableDistros).filter((distro) => { + const key = normalizeWslDistroKey(distro) + if (registered.has(key)) { + const reconciliation = state.reconciliations[key] + return !( + reconciliation && + timing.currentTarget && + reconciliation.target === timing.currentTarget && + reconciliation.appVersion === (timing.appVersion ?? '') + ) + } + const inspectedAt = state.inspectionTimes[key] + return ( + inspectedAt === undefined || inspectedAt > now || now - inspectedAt >= negativeInspectionTtlMs + ) + }) +} + +export function recordWslCliRegistrationObservations( + userDataPath: string, + observations: WslCliRegistrationObservation[], + timing: Pick = {} +): Promise { + const effective = observations.filter( + (observation) => observation.inspected && observation.distro.trim() + ) + if (effective.length === 0) { + return Promise.resolve() + } + return updateState(userDataPath, (state) => { + let registeredDistros = state.registeredDistros + let inspectionTimes = state.inspectionTimes + let reconciliations = state.reconciliations + const now = timing.now ?? Date.now() + for (const observation of effective) { + const key = normalizeWslDistroKey(observation.distro) + inspectionTimes = { ...inspectionTimes, [key]: now } + if (observation.managed === true) { + registeredDistros = upsertDistro(registeredDistros, observation.distro) + } else if (observation.managed === false) { + registeredDistros = removeDistro(registeredDistros, observation.distro) + } + if (observation.reconciled !== undefined) { + if (observation.reconciled === null) { + const { [key]: _removed, ...rest } = reconciliations + reconciliations = rest + } else { + reconciliations = { ...reconciliations, [key]: observation.reconciled } + } + } + } + return { + schemaVersion: REGISTRY_SCHEMA_VERSION, + registeredDistros, + inspectionTimes, + reconciliations + } + }) +} + +export function recordWslCliRegistrationInstalled( + userDataPath: string, + distro: string +): Promise { + return recordWslCliRegistrationObservations(userDataPath, [ + { distro, inspected: true, managed: true } + ]) +} + +export function recordWslCliRegistrationRemoved( + userDataPath: string, + distro: string +): Promise { + return recordWslCliRegistrationObservations(userDataPath, [ + { distro, inspected: true, managed: false, reconciled: null } + ]) +} diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 70e1b16fb25..136a2a682c9 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -71,13 +71,35 @@ export function buildSafeReplaceGuard(path: string, managedMarker: string): stri ].join('\n') } -export function buildSafeRemoveCommand(commandPath: string): string { +export function buildRegistrationLockPrelude(commandPath: string): string { + const lockDir = getPosixDirname(getBridgePathFromCommandPath(commandPath)) + // Why: the per-distro queue only serializes one Orca process; flock covers + // a second install (e.g. stable + nightly) mutating the same distro files. + return [ + `if command -v flock >/dev/null 2>&1 && mkdir -p ${quoteShell(lockDir)} 2>/dev/null; then`, + ` exec 9>${quoteShell(`${lockDir}/.orca-wsl-cli.lock`)}`, + ' flock -x -w 30 9', + 'fi' + ].join('\n') +} + +export function buildManagedLegacyRemoveCommand(quotedLegacyCommandPath: string): string { + // Why: remove only the Orca-managed pre-rename wrapper; user-owned `orca` + // commands and symlinks must survive. + return `if [ ! -L ${quotedLegacyCommandPath} ] && [ -f ${quotedLegacyCommandPath} ] && grep -Fq ${quoteShell(MANAGED_MARKER)} ${quotedLegacyCommandPath}; then rm -f ${quotedLegacyCommandPath}; fi` +} + +export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string { const bridgePath = getBridgePathFromCommandPath(commandPath) return [ 'set -euo pipefail', + buildRegistrationLockPrelude(commandPath), buildSafeReplaceGuard(commandPath, MANAGED_MARKER), buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), - `rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}` + `rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`, + // Why: leaving a managed legacy `orca` behind lets startup reconciliation + // re-adopt it as opt-in proof and silently undo this removal. + ...(legacyCommandPath ? [buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))] : []) ].join('\n') } diff --git a/src/main/index.ts b/src/main/index.ts index 16d3c870c2f..b76964ae872 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -85,6 +85,7 @@ import { import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' +import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { @@ -187,6 +188,7 @@ import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' import { CliInstaller } from './cli/cli-installer' import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' +import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal' let mainWindow: BrowserWindow | null = null @@ -225,6 +227,8 @@ let keybindings: KeybindingService | null = null const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() +let managedWslCliReconciliationReady: Promise = Promise.resolve() +let managedWslCliStartupBarrierReady: Promise = Promise.resolve() // Why: GPU child crashes clustered right after launch indicate a broken driver; // track them so Orca can move this build onto software rendering. const gpuLaunchTimeMs = Date.now() @@ -610,7 +614,9 @@ if (hasSingleInstanceLock) { } ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { - await firstWindowStartupServicesReady + // Why: window rendering and local RPC startup stay independent, but restored + // WSL terminals get a bounded chance to receive launcher repairs first. + await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) ipcMain.handle( @@ -1602,6 +1608,34 @@ app.whenReady().then(async () => { electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) + // Why: managed WSL launchers live outside the Windows app bundle, so keep + // their launcher and bridge contract synchronized across app updates. + managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ + isPackaged: app.isPackaged, + userDataPath: getCanonicalUserDataPath(), + appVersion: app.getVersion() + }) + .then((results) => { + for (const result of results) { + if (result.outcome === 'failed') { + console.warn( + `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` + ) + } else if (result.outcome === 'repaired') { + console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) + } + } + }) + .catch((error) => { + console.warn( + '[wsl-cli] Managed registration reconciliation discovery failed:', + error instanceof Error ? error.message : String(error) + ) + }) + managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( + managedWslCliReconciliationReady + ) + const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') @@ -2056,6 +2090,9 @@ app.whenReady().then(async () => { } if (serveOptions) { + // Why: headless serve has no renderer startup barrier, so settle managed + // WSL command reconciliation before exposing its runtime transport. + await managedWslCliReconciliationReady await startServeAgentHookServer() registerHeadlessPtyRuntime( runtime, diff --git a/src/main/ipc/cli.test.ts b/src/main/ipc/cli.test.ts new file mode 100644 index 00000000000..eeb98539751 --- /dev/null +++ b/src/main/ipc/cli.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + ipcHandleMock, + wslInstallerMock, + recordInstalledMock, + recordRemovedMock, + getDefaultWslDistroMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + ipcHandleMock: vi.fn(), + wslInstallerMock: vi.fn(), + recordInstalledMock: vi.fn(), + recordRemovedMock: vi.fn(), + getDefaultWslDistroMock: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: ipcHandleMock } })) +vi.mock('../cli/cli-installer', () => ({ CliInstaller: vi.fn() })) +vi.mock('../cli/wsl-cli-installer', () => ({ WslCliInstaller: wslInstallerMock })) +vi.mock('../cli/wsl-cli-registration-registry', () => ({ + recordWslCliRegistrationInstalled: recordInstalledMock, + recordWslCliRegistrationRemoved: recordRemovedMock +})) +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/canonical-user-data' })) +vi.mock('../startup/hydrate-shell-path', () => ({ + hydrateShellPath: vi.fn(async () => ({ ok: false })), + mergePathSegments: vi.fn() +})) +vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock })) + +import { registerCliHandlers } from './cli' + +type WslHandler = (event: unknown, args?: { distro?: string | null }) => Promise<{ state: string }> + +function getWslHandler(channel: string): WslHandler { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`Missing IPC handler: ${channel}`) + } + return handler as WslHandler +} + +describe('WSL CLI registration IPC', () => { + beforeEach(() => { + handlers.clear() + ipcHandleMock.mockReset() + ipcHandleMock.mockImplementation( + (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + } + ) + wslInstallerMock.mockReset() + recordInstalledMock.mockReset().mockResolvedValue(undefined) + recordRemovedMock.mockReset().mockResolvedValue(undefined) + getDefaultWslDistroMock.mockReset().mockReturnValue('Ubuntu') + registerCliHandlers() + }) + + it('records a successful explicit-distro installation', async () => { + const install = vi.fn(async () => ({ state: 'installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { install } + }) + + await expect(getWslHandler('cli:installWsl')({}, { distro: ' Debian ' })).resolves.toEqual({ + state: 'installed' + }) + expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Debian' }) + expect(recordInstalledMock).toHaveBeenCalledWith('/canonical-user-data', 'Debian') + }) + + it('records removal from the resolved default distro', async () => { + const remove = vi.fn(async () => ({ state: 'not_installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { remove } + }) + + await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' }) + expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Ubuntu' }) + expect(recordRemovedMock).toHaveBeenCalledWith('/canonical-user-data', 'Ubuntu') + }) + + it('does not claim ownership when installation is not confirmed', async () => { + const install = vi.fn(async () => ({ state: 'unsupported' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { install } + }) + + await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'unsupported' }) + expect(recordInstalledMock).not.toHaveBeenCalled() + }) + + it('keeps successful installation successful when advisory registry persistence fails', async () => { + const install = vi.fn(async () => ({ state: 'installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { install } + }) + recordInstalledMock.mockRejectedValueOnce(new Error('ENOSPC')) + + await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'installed' }) + }) + + it('keeps successful removal successful when advisory registry persistence fails', async () => { + const remove = vi.fn(async () => ({ state: 'not_installed' })) + wslInstallerMock.mockImplementation(function MockWslCliInstaller() { + return { remove } + }) + recordRemovedMock.mockRejectedValueOnce(new Error('EACCES')) + + await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' }) + }) + + it('serializes a concurrent removal after installation and ownership persistence', async () => { + let finishInstall!: () => void + const install = vi.fn( + () => + new Promise<{ state: 'installed' }>((resolve) => { + finishInstall = () => resolve({ state: 'installed' }) + }) + ) + const remove = vi.fn(async () => ({ state: 'not_installed' as const })) + wslInstallerMock + .mockImplementationOnce(function MockInstallWslCliInstaller() { + return { install } + }) + .mockImplementationOnce(function MockRemoveWslCliInstaller() { + return { remove } + }) + + const installation = getWslHandler('cli:installWsl')({}, { distro: 'Ubuntu' }) + await vi.waitFor(() => expect(install).toHaveBeenCalledOnce()) + const removal = getWslHandler('cli:removeWsl')({}, { distro: 'ubuntu' }) + await Promise.resolve() + expect(remove).not.toHaveBeenCalled() + + finishInstall() + await expect(installation).resolves.toEqual({ state: 'installed' }) + await expect(removal).resolves.toEqual({ state: 'not_installed' }) + expect(recordInstalledMock.mock.invocationCallOrder[0]).toBeLessThan( + remove.mock.invocationCallOrder[0] + ) + expect(remove.mock.invocationCallOrder[0]).toBeLessThan( + recordRemovedMock.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index b0b4f4c6311..659c69d9c02 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -1,13 +1,49 @@ import { ipcMain } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' import { CliInstaller } from '../cli/cli-installer' +import { + recordWslCliRegistrationInstalled, + recordWslCliRegistrationRemoved +} from '../cli/wsl-cli-registration-registry' import { WslCliInstaller } from '../cli/wsl-cli-installer' +import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation' +import { getCanonicalUserDataPath } from '../persistence' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getDefaultWslDistro } from '../wsl' function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined { return args?.distro?.trim() || undefined } +function resolveWslCliDistro(args?: { distro?: string | null }): string | null { + return normalizeWslCliDistro(args) ?? getDefaultWslDistro() +} + +function runWslCliRegistrationOperation( + distro: string | null, + operation: () => Promise +): Promise { + return distro ? runSerializedWslCliRegistrationOperation(distro, operation) : operation() +} + +async function persistWslCliRegistration( + operation: () => Promise, + action: 'install' | 'remove' +): Promise { + try { + await operation() + } catch (error) { + // Why: the WSL file operation already succeeded; advisory metadata must + // not turn that success into a false Settings failure. The atomic write + // left the prior registry intact, and repair is disk-authoritative, so a + // stale entry self-corrects on the next startup probe. + console.warn( + `[wsl-cli] Failed to persist ${action} registration metadata:`, + error instanceof Error ? error.message : String(error) + ) + } +} + async function hydrateLocalShellPathForCli(force = false): Promise { if (process.platform === 'win32') { return @@ -39,21 +75,44 @@ export function registerCliHandlers(): void { ipcMain.handle( 'cli:getWslInstallStatus', async (_event, args?: { distro?: string | null }): Promise => { - return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).getStatus() + // Why: status is a read-only probe; queuing it behind a long-running + // repair/install would hang the Settings spinner for its duration, and + // Settings re-polls, so a rare transient read self-corrects. + return new WslCliInstaller({ distro: resolveWslCliDistro(args) }).getStatus() } ) ipcMain.handle( 'cli:installWsl', async (_event, args?: { distro?: string | null }): Promise => { - return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).install() + const distro = resolveWslCliDistro(args) + return runWslCliRegistrationOperation(distro, async () => { + const status = await new WslCliInstaller({ distro }).install() + if (distro && status.state === 'installed') { + await persistWslCliRegistration( + () => recordWslCliRegistrationInstalled(getCanonicalUserDataPath(), distro), + 'install' + ) + } + return status + }) } ) ipcMain.handle( 'cli:removeWsl', async (_event, args?: { distro?: string | null }): Promise => { - return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).remove() + const distro = resolveWslCliDistro(args) + return runWslCliRegistrationOperation(distro, async () => { + const status = await new WslCliInstaller({ distro }).remove() + if (distro && status.state === 'not_installed') { + await persistWslCliRegistration( + () => recordWslCliRegistrationRemoved(getCanonicalUserDataPath(), distro), + 'remove' + ) + } + return status + }) } ) } diff --git a/src/main/ipc/parcel-watcher-child-launch.ts b/src/main/ipc/parcel-watcher-child-launch.ts index cf8e7348bc0..6a72bb98145 100644 --- a/src/main/ipc/parcel-watcher-child-launch.ts +++ b/src/main/ipc/parcel-watcher-child-launch.ts @@ -1,4 +1,5 @@ import { fork, type ChildProcess } from 'node:child_process' +import { writeFileSync } from 'node:fs' import { createWatcherCanaryDirectory, removeWatcherCanaryDirectory @@ -33,6 +34,16 @@ export function launchWatcherChild( console.error('[parcel-watcher-process] failed to fork watcher process:', error) return null } + const faultHarnessPidFile = process.env.ORCA_WATCHER_CHILD_PID_FILE + if (faultHarnessPidFile && child.pid) { + try { + // Why: exclusive creation lets the harness identify the child without a + // leaked test-only environment variable clobbering an existing file. + writeFileSync(faultHarnessPidFile, String(child.pid), { flag: 'wx' }) + } catch { + // Fault-injection observability must never affect watcher availability. + } + } child.stderr?.on('data', (chunk: Buffer) => { console.error('[parcel-watcher-process]', String(chunk).trimEnd()) }) diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts index fd6b9a0257a..71a3d82868f 100644 --- a/src/main/ipc/parcel-watcher-host-subscriptions.ts +++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts @@ -100,6 +100,21 @@ export function resolvePendingWatcherUnsubscribes( pendingUnsubscribes.clear() } +export function disposeWatcherSupervisorSubscriptions( + records: Map, + pendingUnsubscribes: Map void>, + cancelledSubscribesAwaitingChild: Set, + error: Error +): void { + for (const record of records.values()) { + resetPendingSubscribeAttempt(record) + takePendingSubscribe(record)?.reject(error) + } + resolvePendingWatcherUnsubscribes(pendingUnsubscribes) + cancelledSubscribesAwaitingChild.clear() + records.clear() +} + export type CreateHostWatcherSubscriptionOptions = { record: WatcherProcessSubscriptionRecord records: Map diff --git a/src/main/ipc/parcel-watcher-process-failure.ts b/src/main/ipc/parcel-watcher-process-failure.ts index b93f2f37578..80eb79ddd33 100644 --- a/src/main/ipc/parcel-watcher-process-failure.ts +++ b/src/main/ipc/parcel-watcher-process-failure.ts @@ -20,6 +20,13 @@ export class WatcherProcessFailure extends Error { } } +export function watcherHostFailure( + message: string, + code: WatcherProcessFailureCode +): WatcherProcessFailure { + return new WatcherProcessFailure(message, 'supervisor', code) +} + export function isWatcherProcessFailure(error: unknown): error is WatcherProcessFailure { return error instanceof WatcherProcessFailure } diff --git a/src/main/ipc/parcel-watcher-process-supervisor-options.ts b/src/main/ipc/parcel-watcher-process-supervisor-options.ts new file mode 100644 index 00000000000..47f4282a7e6 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-supervisor-options.ts @@ -0,0 +1,4 @@ +export type WatcherProcessSupervisorOptions = { + entryPath?: string + useInProcessVitestFallback?: boolean +} diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts index 40a9c193b4c..9be9b232b08 100644 --- a/src/main/ipc/parcel-watcher-process-supervisor.ts +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -5,6 +5,7 @@ import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' import { launchWatcherChild } from './parcel-watcher-child-launch' import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' import { + disposeWatcherSupervisorSubscriptions, failAllWatcherSubscriptions, handleWatcherHostMessage, reportWatcherTerminalError, @@ -16,7 +17,8 @@ import { startPendingSubscribeTimeout, takePendingSubscribe } from './parcel-watcher-pending-subscribe' -import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import { watcherHostFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessFailure } from './parcel-watcher-process-failure' import type { HostToWatcherMessage, WatcherProcessSubscribeOptions, @@ -28,6 +30,7 @@ import type { WatcherProcessSubscription, WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' +import type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options' import { subscribeThroughWatcherSupervisor } from './parcel-watcher-supervisor-subscribe' export type { @@ -35,6 +38,7 @@ export type { WatcherProcessHooks, WatcherProcessSubscription } from './parcel-watcher-process-subscription' +export type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options' export class WatcherProcessSupervisor { private child: ChildProcess | null = null @@ -45,6 +49,9 @@ export class WatcherProcessSupervisor { private readonly records = new Map() private readonly pendingUnsubscribes = new Map void>() private readonly cancelledSubscribesAwaitingChild = new Set() + + constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {} + subscribe( dir: string, callback: WatcherProcessCallback, @@ -57,6 +64,8 @@ export class WatcherProcessSupervisor { opts, hooks, shutdownRequested: this.shutdownRequested, + entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(), + useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true, allocateId: () => this.nextSubscriptionId++, records: this.records, pendingUnsubscribes: this.pendingUnsubscribes, @@ -73,19 +82,13 @@ export class WatcherProcessSupervisor { this.shutdownRequested = true const proc = this.child this.child = null - const error = new WatcherProcessFailure( - 'file watcher supervisor disposed', - 'supervisor', - 'supervisor_disposed' + const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed') + disposeWatcherSupervisorSubscriptions( + this.records, + this.pendingUnsubscribes, + this.cancelledSubscribesAwaitingChild, + error ) - for (const record of this.records.values()) { - resetPendingSubscribeAttempt(record) - const pending = takePendingSubscribe(record) - pending?.reject(error) - } - resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes) - this.cancelledSubscribesAwaitingChild.clear() - this.records.clear() proc?.kill() this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) } @@ -96,7 +99,9 @@ export class WatcherProcessSupervisor { this.crashFuse.reset() } - private ensureWatcherProcess(entryPath = getWatcherProcessEntryPath()): ChildProcess | null { + private ensureWatcherProcess( + entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() + ): ChildProcess | null { if (this.shutdownRequested) { return null } @@ -200,11 +205,7 @@ export class WatcherProcessSupervisor { ) failAllWatcherSubscriptions( this.records, - new WatcherProcessFailure( - 'file watcher process crashed repeatedly', - 'supervisor', - 'supervisor_crash_fuse' - ) + watcherHostFailure('file watcher process crashed repeatedly', 'supervisor_crash_fuse') ) this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) return @@ -279,9 +280,8 @@ export class WatcherProcessSupervisor { if (!replacement) { failAllWatcherSubscriptions( this.records, - new WatcherProcessFailure( + watcherHostFailure( 'file watcher process unavailable after subscription cancellation', - 'supervisor', 'process_unavailable' ) ) diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index 27891840012..1809ad484a7 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -4,21 +4,28 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { forkMock, existsSyncMock, mkdtempSyncMock, parcelSubscribeMock, rmSyncMock } = vi.hoisted( - () => ({ - forkMock: vi.fn(), - existsSyncMock: vi.fn(), - mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'), - parcelSubscribeMock: vi.fn(), - rmSyncMock: vi.fn() - }) -) +const { + forkMock, + existsSyncMock, + mkdtempSyncMock, + parcelSubscribeMock, + rmSyncMock, + writeFileSyncMock +} = vi.hoisted(() => ({ + forkMock: vi.fn(), + existsSyncMock: vi.fn(), + mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'), + parcelSubscribeMock: vi.fn(), + rmSyncMock: vi.fn(), + writeFileSyncMock: vi.fn() +})) vi.mock('node:child_process', () => ({ fork: forkMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock, mkdtempSync: mkdtempSyncMock, - rmSync: rmSyncMock + rmSync: rmSyncMock, + writeFileSync: writeFileSyncMock })) vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock })) @@ -35,6 +42,7 @@ type SentMessage = { op: string; id: number; dir?: string } class FakeChild extends EventEmitter { connected = true + pid = 1234 sent: SentMessage[] = [] stderr = new EventEmitter() kill = vi.fn(() => { @@ -94,6 +102,19 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(null, events) }) + it('creates the fault-harness pid file without clobbering an existing path', async () => { + vi.stubEnv('ORCA_WATCHER_CHILD_PID_FILE', '/tmp/orca-watcher.pid') + + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + + expect(writeFileSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher.pid', '1234', { + flag: 'wx' + }) + ackSubscribe(child) + await promise + }) + it('forwards watcher errors to the callback', async () => { const callback = vi.fn() const promise = subscribeViaWatcherProcess('/repo', callback, {}) diff --git a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts index 3f0fd8ad415..abfa9ffe540 100644 --- a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts +++ b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts @@ -1,6 +1,5 @@ import type { ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' -import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' import { createHostWatcherSubscription } from './parcel-watcher-host-subscriptions' import { subscribeWithInProcessWatcher } from './parcel-watcher-in-process-fallback' import { installPendingSubscribeControls } from './parcel-watcher-pending-subscribe' @@ -22,6 +21,8 @@ type WatcherSupervisorSubscribeOptions = { opts: WatcherProcessSubscribeOptions hooks: WatcherProcessHooks shutdownRequested: boolean + entryPath: string + useInProcessVitestFallback: boolean allocateId: () => number records: Map pendingUnsubscribes: Map void> @@ -42,6 +43,8 @@ export function subscribeThroughWatcherSupervisor({ opts, hooks, shutdownRequested, + entryPath, + useInProcessVitestFallback, allocateId, records, pendingUnsubscribes, @@ -72,10 +75,9 @@ export function subscribeThroughWatcherSupervisor({ } // Why: under Vitest we cannot fork a real watcher child, so exercise the // subscription path in-process (against mocked @parcel/watcher) instead. - if (process.env.VITEST) { + if (process.env.VITEST && useInProcessVitestFallback) { return subscribeWithInProcessWatcher(dir, callback, opts, hooks) } - const entryPath = getWatcherProcessEntryPath() if (!existsSync(entryPath)) { return Promise.reject( new WatcherProcessFailure( diff --git a/src/main/rate-limits/grok-auth.test.ts b/src/main/rate-limits/grok-auth.test.ts index 793e3e7a694..7bf8f9e62b3 100644 --- a/src/main/rate-limits/grok-auth.test.ts +++ b/src/main/rate-limits/grok-auth.test.ts @@ -45,4 +45,112 @@ describe('readGrokAuthSession', () => { error: 'Grok auth file is invalid' }) }) + + it.each(['https://auth.x.ai', 'https://auth.x.ai::client'])( + 'prefers the %s issuer entry over an earlier alternate issuer', + async (preferredIssuer) => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://stale.example.com::client': { + key: 'stale-token', + user_id: 'stale-user', + email: 'stale@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + }, + [preferredIssuer]: { + key: 'live-token', + user_id: 'live-user', + email: 'live@example.com', + team_id: 'team-1', + expires_at: '2099-06-01T00:00:00.000Z', + oidc_client_id: 'client-1' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'live-token', + userId: 'live-user', + email: 'live@example.com', + teamId: 'team-1', + expiresAtMs: Date.parse('2099-06-01T00:00:00.000Z'), + oidcClientId: 'client-1' + } + }) + } + ) + + it('falls back to the first tokenized entry when no auth.x.ai key exists', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { + key: 'alt-token', + user_id: 'alt-user', + email: 'alt@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ + status: 'ok', + session: { + accessToken: 'alt-token', + userId: 'alt-user', + email: 'alt@example.com', + teamId: null, + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + oidcClientId: null + } + }) + }) + + it('skips an expired auth.x.ai client entry when a fresh one follows it', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://auth.x.ai::old-client': { + key: 'expired-token', + expires_at: '2020-01-01T00:00:00.000Z' + }, + 'https://auth.x.ai::current-client': { + key: 'fresh-token', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toMatchObject({ + status: 'ok', + session: { accessToken: 'fresh-token' } + }) + }) + + it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { key: 'stale-token' }, + 'https://auth.x.ai::client': { user_id: 'signed-out-user' } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') + + expect(readGrokAuthSession()).toEqual({ status: 'missing' }) + }) }) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index 10ba629ff34..e055c022975 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -29,6 +29,8 @@ type GrokAuthEntry = { oidc_client_id?: string } +type TokenizedGrokAuthEntry = GrokAuthEntry & { key: string } + export type GrokAuthReadResult = | { status: 'missing' } | { status: 'error'; error: string } @@ -43,7 +45,7 @@ function getGrokAuthReadError(err: unknown): string { return 'Unable to read Grok auth file' } -function parseAuthEntry(value: unknown): GrokAuthEntry | null { +function parseAuthEntry(value: unknown): TokenizedGrokAuthEntry | null { if (typeof value !== 'object' || value === null) { return null } @@ -51,7 +53,7 @@ function parseAuthEntry(value: unknown): GrokAuthEntry | null { if (typeof entry.key !== 'string' || entry.key.length === 0) { return null } - return entry + return entry as TokenizedGrokAuthEntry } function parseExpiresAtMs(iso: string | undefined): number | null { @@ -62,6 +64,24 @@ function parseExpiresAtMs(iso: string | undefined): number | null { return Number.isFinite(ms) ? ms : null } +// Why: stale alternate issuers can precede the default xAI OAuth session in auth.json. +const PREFERRED_GROK_AUTH_ISSUER = 'https://auth.x.ai' + +function sessionFromAuthEntry(authEntry: TokenizedGrokAuthEntry): GrokAuthSession { + return { + accessToken: authEntry.key, + userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, + email: typeof authEntry.email === 'string' ? authEntry.email : null, + teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, + expiresAtMs: parseExpiresAtMs(authEntry.expires_at), + oidcClientId: typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + } +} + +function isPreferredGrokAuthKey(key: string): boolean { + return key === PREFERRED_GROK_AUTH_ISSUER || key.startsWith(`${PREFERRED_GROK_AUTH_ISSUER}::`) +} + export function readGrokAuthSession(): GrokAuthReadResult { const path = getGrokAuthPath() if (!existsSync(path)) { @@ -72,23 +92,32 @@ export function readGrokAuthSession(): GrokAuthReadResult { if (typeof parsed !== 'object' || parsed === null) { return { status: 'error', error: 'Grok auth file is invalid' } } - for (const entry of Object.values(parsed)) { + let preferredKeySeen = false + let expiredPreferred: GrokAuthSession | null = null + let fallback: GrokAuthSession | null = null + for (const [key, entry] of Object.entries(parsed as Record)) { + const isPreferred = isPreferredGrokAuthKey(key) + preferredKeySeen ||= isPreferred const authEntry = parseAuthEntry(entry) - if (!authEntry?.key) { + if (!authEntry) { continue } - return { - status: 'ok', - session: { - accessToken: authEntry.key, - userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null, - email: typeof authEntry.email === 'string' ? authEntry.email : null, - teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null, - expiresAtMs: parseExpiresAtMs(authEntry.expires_at), - oidcClientId: - typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null + const session = sessionFromAuthEntry(authEntry) + if (isPreferred) { + if (isGrokAccessTokenFresh(session)) { + return { status: 'ok', session } } + expiredPreferred ??= session + continue } + if (!fallback) { + fallback = session + } + } + // Why: alternate issuers are compatibility fallbacks only when no default entry exists. + const selectedSession = expiredPreferred ?? (preferredKeySeen ? null : fallback) + if (selectedSession) { + return { status: 'ok', session: selectedSession } } // Why: a token-less file (e.g. after grok logout) means signed out, not a // failure — 'error' would keep a status-bar alert visible for that user. diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index 2f8c48d1a6c..c55426c4216 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -22,7 +22,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # with subject like "Failed: " — never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ --task-id task_SNAP --dispatch-id ctx_SNAP \\ @@ -39,7 +39,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type heartbeat --subject "alive" \\ --task-id task_SNAP --dispatch-id ctx_SNAP \\ --phase "" @@ -56,20 +56,20 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # blocks on \`check --wait\` until the coordinator replies, then prints the # reply body. Use it anywhere you would otherwise have reached for # AskUserQuestion. - orca orchestration ask --to term_COORD \\ + orca orchestration ask --to term_COORD --from term_WORKER \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - orca orchestration send --to term_COORD \\ + orca orchestration send --to term_COORD --from term_WORKER \\ --type escalation --subject "Blocked: " \\ --body "
" \\ --task-id task_SNAP # Check for messages from the coordinator: - orca orchestration check + orca orchestration check --terminal term_WORKER === AFTER YOU SEND worker_done === diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 1e94018c931..8563c3a1e87 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -76,6 +76,7 @@ function insertWorkerDone( from?: string dispatchId?: string filesModified?: string[] + senderPaneKey?: string } ): void { const dispatch = db.getDispatchContext(params.taskId) @@ -83,8 +84,9 @@ function insertWorkerDone( if (!dispatchId) { throw new Error(`No dispatch for task ${params.taskId}`) } + const from = params.from ?? dispatch?.assignee_handle ?? 'term_unknown' db.insertMessage({ - from: params.from ?? dispatch?.assignee_handle ?? 'term_unknown', + from, to: params.to ?? 'coord', subject: 'Done', type: 'worker_done', @@ -92,7 +94,10 @@ function insertWorkerDone( taskId: params.taskId, dispatchId, ...(params.filesModified ? { filesModified: params.filesModified } : {}) - }) + }), + senderPaneKey: + params.senderPaneKey ?? + (from === dispatch?.assignee_handle ? (dispatch.assignee_pane_key ?? undefined) : undefined) }) } @@ -595,20 +600,22 @@ describe('Coordinator', () => { expect(db.getDispatchContextById(activeCtx.id)?.status).toBe('completed') }) - it('accepts worker_done payload provenance after an assignee handle changes', async () => { + it('accepts worker_done pane provenance after an assignee handle changes', async () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() const logs: string[] = [] const task = db.createTask({ spec: 'owned work' }) - const ctx = db.createDispatchContext(task.id, 'term_owner') + const leafId = '11111111-1111-4111-8111-111111111111' + const ctx = db.createDispatchContext(task.id, 'term_owner', `tab_before:${leafId}`) db.insertMessage({ from: 'term_reminted', to: 'coord', subject: 'Done after restart', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }), + senderPaneKey: `tab_after:${leafId}` }) const coordinator = new Coordinator(db, runtime, { @@ -622,7 +629,7 @@ describe('Coordinator', () => { expect(result.status).toBe('completed') expect(db.getTask(task.id)?.status).toBe('completed') expect(db.getDispatchContextById(ctx.id)?.status).toBe('completed') - expect(logs.some((m) => m.includes('accepting payload provenance'))).toBe(true) + expect(logs.some((m) => m.includes('Task') && m.includes('completed'))).toBe(true) }) it('can be stopped', async () => { diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index e09e48942d3..73307eddd35 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -484,6 +484,7 @@ export class Coordinator { // part of its instructions). taskSpec: strippedSpec, coordinatorHandle: this.opts.coordinatorHandle, + workerHandle: targetHandle, devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev'), // Why (§3.2): drift section fires only when behind > 0. The preamble // builder gates on this itself; passing the object unconditionally lets diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index ddbdb54a0f6..80a842da530 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -46,6 +46,22 @@ function generateId(prefix: string): string { return `${prefix}_${randomBytes(6).toString('hex')}` } +function addLifecycleRejectionMarker(payload: string | null, reason: string): string { + let parsed: Record = {} + try { + const value: unknown = payload ? JSON.parse(payload) : {} + if (value && typeof value === 'object' && !Array.isArray(value)) { + parsed = value as Record + } + } catch { + // Authority reconciliation only reaches this path with object payloads. + } + return JSON.stringify({ + ...parsed, + _orcaLifecycleRejection: { code: 'sender_not_assignee', reason } + }) +} + // Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at` // column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so // push-on-idle can distinguish queued-but-undelivered from user-acknowledged @@ -354,6 +370,27 @@ export class OrchestrationDb { .all(toHandle) as MessageRow[] } + convertLifecycleMessageToRejection(messageId: string, reason: string): MessageRow | undefined { + const message = this.getMessageById(messageId) + if (!message || (message.type !== 'worker_done' && message.type !== 'heartbeat')) { + return message + } + + const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : '' + const body = `Orca rejected this ${message.type}: ${reason}${originalBody}` + const payload = addLifecycleRejectionMarker(message.payload, reason) + // Why: rejected lifecycle signals must remain auditable without reaching + // later read paths as actionable completion or liveness events. + this.db + .prepare( + `UPDATE messages + SET priority = 'high', subject = ?, body = ?, payload = ? + WHERE id = ?` + ) + .run(`Rejected ${message.type}: ${message.subject}`, body, payload, messageId) + return this.getMessageById(messageId) + } + // Why: push-on-idle delivery must not replay messages that were already // injected into the PTY. `read` flips only when a check-caller consumes a // message, so delivered-but-unread rows would otherwise be re-injected on diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index f932aec359c..ea8ae94bc19 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -71,7 +71,9 @@ describe('formatMessageBanner', () => { it('includes reply hint with message ID', () => { const banner = formatMessageBanner(makeMessage({ id: 'msg_xyz789' })) - expect(banner).toContain('[Reply: orca orchestration reply --id msg_xyz789 --body "..."]') + expect(banner).toContain( + '[Reply: orca orchestration reply --id msg_xyz789 --from term_coord --body "..."]' + ) }) it('ends with a separator line', () => { diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index f5526553d3b..a86f3c61edf 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -25,7 +25,11 @@ export function formatMessageBanner(msg: MessageRow): string { lines.push(`[Payload: ${msg.payload}]`) } - lines.push(`[Reply: orca orchestration reply --id ${msg.id} --body "..."]`) + // Why: injected reply commands must retain the receiving pane's identity + // even when an older shell lacks Orca's terminal environment variables. + lines.push( + `[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]` + ) lines.push(SEPARATOR) return lines.join('\n') diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts index 3ba68a67910..8fed046d421 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts @@ -7,7 +7,7 @@ describe('lifecycle reconciliation', () => { afterEach(() => db?.close()) - it('completes an active dispatch from payload IDs after its terminal handle is reminted', () => { + it('rejects handle churn when neither side has stable pane identity', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_before_restart') @@ -20,14 +20,13 @@ describe('lifecycle reconciliation', () => { payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) }) - expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toEqual({ - action: 'completed', - taskId: task.id, - dispatchId: dispatch.id + expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' }) - expect(db.getTask(task.id)?.status).toBe('completed') - expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') - expect(logs.some((line) => line.includes('accepting payload provenance'))).toBe(true) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + expect(logs.some((line) => line.includes('expected handle'))).toBe(true) }) // Real leaf UUIDs: pane keys are `${tabId}:${leafUuid}` and only the leaf @@ -71,7 +70,7 @@ describe('lifecycle reconciliation', () => { expect(db.getTask(task.id)?.status).toBe('completed') }) - it('completes worker_done when a pane key is unparseable (legacy format)', () => { + it('rejects mismatched opaque pane keys instead of treating them as legacy', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w:${LEAF_A}`) @@ -84,10 +83,194 @@ describe('lifecycle reconciliation', () => { senderPaneKey: 'tab_w:42' }) - expect(reconcileLifecycleMessage(db, message).action).toBe('completed') + expect(reconcileLifecycleMessage(db, message).action).toBe('rejected') + expect(db.getTask(task.id)?.status).toBe('dispatched') }) - it('ignores worker_done sent from a different pane', () => { + it('rejects worker_done from a foreign pane that claims the assignee handle', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, message)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee', + reason: expect.stringContaining('expected handle term_owner') + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getMessageById(message.id)).toMatchObject({ + type: 'worker_done', + priority: 'high', + subject: 'Rejected worker_done: Done', + body: expect.stringContaining('Orca rejected this worker_done') + }) + const persisted = db.getMessageById(message.id) + expect(JSON.parse(persisted?.payload ?? '{}')).toMatchObject({ + taskId: task.id, + dispatchId: dispatch.id, + _orcaLifecycleRejection: { code: 'sender_not_assignee' } + }) + const rereadLogs: string[] = [] + expect( + persisted && reconcileLifecycleMessage(db, persisted, (line) => rereadLogs.push(line)) + ).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + // Why: the send-path conversion logs nothing (no-op logger), so the + // coordinator's re-read must still surface the rejection to its log stream. + expect(rereadLogs.some((line) => line.includes('worker_done rejected'))).toBe(true) + }) + + it('does not let a caller-supplied rejection marker turn completion into success', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + _orcaLifecycleRejection: { + code: 'sender_not_assignee', + reason: 'caller supplied' + } + }), + senderPaneKey: `tab_w:${LEAF_A}` + }) + + expect(reconcileLifecycleMessage(db, message)).toEqual({ + action: 'rejected', + code: 'sender_not_assignee', + reason: 'caller supplied' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rejects a coordinator completion for a pane-bound dispatch', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_coordinator', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(reconcileLifecycleMessage(db, message)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('uses exact handle equality only for a legacy dispatch without a pane key', () => { + db = new OrchestrationDb(':memory:') + const acceptedTask = db.createTask({ spec: 'legacy work' }) + const acceptedDispatch = db.createDispatchContext(acceptedTask.id, 'term_legacy') + const accepted = db.insertMessage({ + from: 'term_legacy', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: acceptedTask.id, dispatchId: acceptedDispatch.id }) + }) + expect(reconcileLifecycleMessage(db, accepted).action).toBe('completed') + + const rejectedTask = db.createTask({ spec: 'other legacy work' }) + const rejectedDispatch = db.createDispatchContext(rejectedTask.id, 'term_other_legacy') + const rejected = db.insertMessage({ + from: 'term_foreign', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: rejectedTask.id, dispatchId: rejectedDispatch.id }) + }) + expect(reconcileLifecycleMessage(db, rejected)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(rejectedTask.id)?.status).toBe('dispatched') + }) + + it('does not release a dependent when a foreign completion wins the arrival race', () => { + db = new OrchestrationDb(':memory:') + const parent = db.createTask({ spec: 'parent' }) + const child = db.createTask({ spec: 'child', deps: [parent.id] }) + const dispatch = db.createDispatchContext(parent.id, 'term_worker', `tab_w:${LEAF_A}`) + const payload = JSON.stringify({ taskId: parent.id, dispatchId: dispatch.id }) + + const foreign = db.insertMessage({ + from: 'term_coordinator', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_c:${LEAF_B}` + }) + expect(reconcileLifecycleMessage(db, foreign)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(child.id)?.status).toBe('pending') + + const owner = db.insertMessage({ + from: 'term_worker_reminted', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_w_after:${LEAF_A}` + }) + expect(reconcileLifecycleMessage(db, owner).action).toBe('completed') + expect(db.getTask(child.id)?.status).toBe('ready') + }) + + it('does not let a foreign replay overwrite an authorized completion', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + const owner = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload, + senderPaneKey: `tab_w:${LEAF_A}` + }) + expect(reconcileLifecycleMessage(db, owner).action).toBe('completed') + const result = db.getTask(task.id)?.result + + const replay = db.insertMessage({ + from: 'term_foreign', + to: 'term_coordinator', + subject: 'Forged replay', + type: 'worker_done', + payload, + senderPaneKey: `tab_f:${LEAF_B}` + }) + expect(reconcileLifecycleMessage(db, replay)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getTask(task.id)?.result).toBe(result) + }) + + it('surfaces worker_done sent from a different pane as rejected', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) @@ -101,15 +284,16 @@ describe('lifecycle reconciliation', () => { senderPaneKey: `tab_w2:${LEAF_B}` }) - expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toEqual({ - action: 'ignored' + expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' }) expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') - expect(logs.some((line) => line.includes(`expected pane tab_w1:${LEAF_A}`))).toBe(true) + expect(logs.some((line) => line.includes('worker_done rejected'))).toBe(true) }) - it('ignores a heartbeat sent from a different pane without recording liveness', () => { + it('surfaces a heartbeat sent from a different pane without recording liveness', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) @@ -122,7 +306,44 @@ describe('lifecycle reconciliation', () => { senderPaneKey: `tab_w2:${LEAF_B}` }) - expect(reconcileLifecycleMessage(db, heartbeat)).toEqual({ action: 'ignored' }) + expect(reconcileLifecycleMessage(db, heartbeat)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) + expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeNull() + expect(db.getMessageById(heartbeat.id)).toMatchObject({ + type: 'heartbeat', + subject: 'Rejected heartbeat: alive' + }) + // Why: the coordinator's re-read of the already-converted heartbeat must + // still log the rejection, since the send-path conversion logged nothing. + const persisted = db.getMessageById(heartbeat.id) + const rereadLogs: string[] = [] + expect( + persisted && reconcileLifecycleMessage(db, persisted, (line) => rereadLogs.push(line)) + ).toMatchObject({ + action: 'rejected' + }) + expect(rereadLogs.some((line) => line.includes('Heartbeat rejected'))).toBe(true) + }) + + it('surfaces a foreign heartbeat that claims the assignee handle', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_owner', `tab_w1:${LEAF_A}`) + const heartbeat = db.insertMessage({ + from: 'term_owner', + to: 'term_coordinator', + subject: 'alive', + type: 'heartbeat', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + senderPaneKey: `tab_w2:${LEAF_B}` + }) + + expect(reconcileLifecycleMessage(db, heartbeat)).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee' + }) expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeNull() }) diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts index d784840a28c..96917babaf6 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -2,17 +2,29 @@ import type { OrchestrationDb } from './db' import type { MessageRow } from './types' import { parsePaneKey } from '../../../shared/stable-pane-id' -// Why: the tab half of a pane key changes when a pane is broken out into its -// own tab, so only the leaf UUID is identity. Reject only when both keys -// parse and name different leaves; an unparseable (e.g. legacy numeric) key -// degrades to payload authority rather than stranding a completion. -function isForeignPane(assigneePaneKey: string, senderPaneKey: string): boolean { +// Why: the tab half can change on pane break-out, while opaque legacy keys +// have no safe equivalence beyond exact equality. +function isSamePane(assigneePaneKey: string, senderPaneKey: string): boolean { if (assigneePaneKey === senderPaneKey) { - return false + return true } const assigneeLeaf = parsePaneKey(assigneePaneKey)?.leafId const senderLeaf = parsePaneKey(senderPaneKey)?.leafId - return Boolean(assigneeLeaf && senderLeaf && assigneeLeaf !== senderLeaf) + return Boolean(assigneeLeaf && senderLeaf && assigneeLeaf === senderLeaf) +} + +function hasLifecycleAuthority( + dispatch: { assignee_handle: string | null; assignee_pane_key: string | null }, + msg: MessageRow +): boolean { + if (dispatch.assignee_pane_key) { + return Boolean( + msg.sender_pane_key && isSamePane(dispatch.assignee_pane_key, msg.sender_pane_key) + ) + } + // Why: rows created before pane identity existed can only use the exact + // handle recorded at dispatch; payload knowledge alone is not authority. + return dispatch.assignee_handle === msg.from_handle } export type LifecycleReconciliationResult = @@ -21,9 +33,16 @@ export type LifecycleReconciliationResult = // read); senders must not wake waiters for it, unlike `ignored` rows that // stay unread and still need delivery. | { action: 'suppressed' } + | LifecycleRejectionResult | { action: 'completed'; taskId: string; dispatchId: string } | { action: 'heartbeat_recorded'; dispatchId: string } +export type LifecycleRejectionResult = { + action: 'rejected' + code: 'sender_not_assignee' + reason: string +} + type LogFn = (msg: string) => void const noopLog: LogFn = () => {} @@ -42,6 +61,27 @@ function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record< } } +function getPersistedLifecycleRejection( + payload: Record +): LifecycleRejectionResult | undefined { + const rejection = payload._orcaLifecycleRejection + if ( + !rejection || + typeof rejection !== 'object' || + (rejection as { code?: unknown }).code !== 'sender_not_assignee' || + typeof (rejection as { reason?: unknown }).reason !== 'string' + ) { + return undefined + } + // Why: the marker is reserved persistence state; treating it as a rejection + // also prevents caller-supplied markers from turning lifecycle sends into success. + return { + action: 'rejected', + code: 'sender_not_assignee', + reason: (rejection as { reason: string }).reason + } +} + export function reconcileLifecycleMessage( db: OrchestrationDb, msg: MessageRow, @@ -75,6 +115,13 @@ function reconcileHeartbeatMessage( const payload = parseObjectPayload(msg, () => { onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`) }) + const persistedRejection = getPersistedLifecycleRejection(payload) + if (persistedRejection) { + // Why: the send-path reconcile converts with a no-op logger, so the + // coordinator's re-read is the only chance to surface the rejection. + onLog(`Heartbeat rejected: ${persistedRejection.reason}`) + return persistedRejection + } const dispatchId = payload.dispatchId if (typeof dispatchId !== 'string' || dispatchId.length === 0) { onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`) @@ -90,17 +137,13 @@ function reconcileHeartbeatMessage( return { action: 'suppressed' } } - if ( - dispatch.assignee_pane_key && - msg.sender_pane_key && - isForeignPane(dispatch.assignee_pane_key, msg.sender_pane_key) - ) { + if (!hasLifecycleAuthority(dispatch, msg)) { // Why: a wrong-pane heartbeat must not refresh liveness — it would mask // a hung assignee behind another agent's timer. - onLog( - `Heartbeat for dispatch ${dispatchId} came from pane ${msg.sender_pane_key}, expected pane ${dispatch.assignee_pane_key}; ignored` - ) - return { action: 'ignored' } + const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) + onLog(`Heartbeat rejected: ${reason}`) + db.convertLifecycleMessageToRejection(msg.id, reason) + return { action: 'rejected', code: 'sender_not_assignee', reason } } // Why: dispatchId-specific writes let the DB ignore late heartbeats for @@ -119,6 +162,13 @@ function reconcileWorkerDoneMessage( const payload = parseObjectPayload(msg, () => { onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) }) + const persistedRejection = getPersistedLifecycleRejection(payload) + if (persistedRejection) { + // Why: the send-path reconcile converts with a no-op logger, so the + // coordinator's re-read is the only chance to surface the rejection. + onLog(`Warning: worker_done rejected: ${persistedRejection.reason}`) + return persistedRejection + } const taskId = payload.taskId if (typeof taskId !== 'string' || taskId.length === 0) { @@ -151,26 +201,11 @@ function reconcileWorkerDoneMessage( ) return { action: 'ignored' } } - if (dispatch.assignee_handle !== msg.from_handle) { - // Why: pane leaves are the remint-stable identity behind handles. When - // both sides carry one, a foreign leaf is a different pane completing - // someone else's task — reject it; the same leaf is the same pane after - // a handle remint or tab break-out. Without pane data (older CLI, - // sessions without ORCA_PANE_KEY) payload IDs stay the completion - // authority. - if ( - dispatch.assignee_pane_key && - msg.sender_pane_key && - isForeignPane(dispatch.assignee_pane_key, msg.sender_pane_key) - ) { - onLog( - `Warning: worker_done for dispatch ${dispatchId} came from pane ${msg.sender_pane_key}, expected pane ${dispatch.assignee_pane_key}; ignored` - ) - return { action: 'ignored' } - } - onLog( - `Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? ''}; accepting payload provenance` - ) + if (!hasLifecycleAuthority(dispatch, msg)) { + const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) + onLog(`Warning: worker_done rejected: ${reason}`) + db.convertLifecycleMessageToRejection(msg.id, reason) + return { action: 'rejected', code: 'sender_not_assignee', reason } } // Why: `orchestration.send` can release the DB lock before waking the // coordinator; the later coordinator read still needs to observe completion. @@ -204,6 +239,18 @@ function reconcileWorkerDoneMessage( return { action: 'completed', taskId, dispatchId } } +function buildLifecycleAuthorityRejectionReason( + dispatchId: string, + dispatch: { assignee_handle: string | null; assignee_pane_key: string | null }, + msg: MessageRow +): string { + return ( + `dispatch ${dispatchId} expected handle ${dispatch.assignee_handle ?? ''}, ` + + `pane ${dispatch.assignee_pane_key ?? ''}; received handle ${msg.from_handle}, ` + + `pane ${msg.sender_pane_key ?? ''}` + ) +} + function suppressEarlierHeartbeats( db: OrchestrationDb, workerDone: MessageRow, diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 23fac4843ab..198437e4cd7 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -8,6 +8,7 @@ function baseParams(overrides: Partial[ dispatchId: 'ctx_def456', taskSpec: 'Implement the login form', coordinatorHandle: 'term_coord', + workerHandle: 'term_worker', ...overrides } } @@ -46,6 +47,7 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--dispatch-id ctx_def456') expect(result).toContain('--files-modified "path/a,path/b"') expect(result).toContain('--report-path ""') + expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) }) it( @@ -83,11 +85,12 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--task-id task_abc123') expect(result).toContain('--dispatch-id ctx_def456') expect(result).toContain('--phase ""') + expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) }) it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => { const result = buildDispatchPreamble(baseParams()) - expect(result).toContain('orchestration ask') + expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) expect(result).toContain('--question') expect(result).toContain('--timeout-ms 600000') // Why: the exact phrase is asserted so the rule can't be trimmed away by @@ -103,6 +106,16 @@ describe('buildDispatchPreamble', () => { expect(occurrences).toBe(3) }) + it('binds every injected worker command to the dispatched terminal', () => { + const result = buildDispatchPreamble(baseParams()) + + expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) + expect(result).toMatch( + /orchestration send --to term_coord --from term_worker \\\n --type escalation/ + ) + expect(result).toContain('orchestration check --terminal term_worker') + }) + it('tells prompt-returning workers to idle without post-done polling', () => { const result = buildDispatchPreamble(baseParams()) const section = afterWorkerDoneSection(result) @@ -168,6 +181,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 7, @@ -190,6 +204,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 0, @@ -206,7 +221,8 @@ describe('buildDispatchPreamble', () => { taskId: 'task_x', dispatchId: 'ctx_x', taskSpec: 'do stuff', - coordinatorHandle: 'term_c' + coordinatorHandle: 'term_c', + workerHandle: 'term_w' }) expect(result).not.toContain('--- BASE DRIFT ---') @@ -219,6 +235,7 @@ describe('buildDispatchPreamble', () => { dispatchId: 'ctx_x', taskSpec: 'do stuff', coordinatorHandle: 'term_c', + workerHandle: 'term_w', baseDrift: { base: 'origin/main', behind: 3, @@ -241,7 +258,8 @@ describe('buildDispatchPreamble', () => { taskId: 'task_SNAP', dispatchId: 'ctx_SNAP', taskSpec: 'TASK_BODY', - coordinatorHandle: 'term_COORD' + coordinatorHandle: 'term_COORD', + workerHandle: 'term_WORKER' }) expect(result).toMatchSnapshot() }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index fdee65505e5..9aaa488318f 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -8,6 +8,7 @@ export type PreambleParams = { dispatchId: string taskSpec: string coordinatorHandle: string + workerHandle: string devMode?: boolean // Why: populated by the coordinator's dispatch pre-flight (§3.1) only // when the target worktree is behind its tracking remote. When absent @@ -68,7 +69,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # with subject like "Failed: " — never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ @@ -85,7 +86,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type heartbeat --subject "alive" \\ --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ --phase "" @@ -102,20 +103,20 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # blocks on \`check --wait\` until the coordinator replies, then prints the # reply body. Use it anywhere you would otherwise have reached for # AskUserQuestion. - ${cli} orchestration ask --to ${params.coordinatorHandle} \\ + ${cli} orchestration ask --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - ${cli} orchestration send --to ${params.coordinatorHandle} \\ + ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ --type escalation --subject "Blocked: " \\ --body "
" \\ --task-id ${params.taskId} # Check for messages from the coordinator: - ${cli} orchestration check + ${cli} orchestration check --terminal ${params.workerHandle} ${postDoneInstructions}` diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index cf0c722c675..fc7536c2e4f 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -105,6 +105,100 @@ describe('orchestration RPC methods', () => { expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_a:leaf_a') }) + it('recovers missing sender pane identity from the resolved handle', async () => { + setup() + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'hello' + })) as { message: { id: string } } + + expect(runtime.getTerminalPaneKey).toHaveBeenCalledWith('term_worker') + expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_worker:leaf_worker') + }) + + it('completes an identity-less injected send through its explicit worker handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + }) + + it('rejects an identity-less lifecycle send resolved through the coordinator handle', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dependent = db.createTask({ spec: 'dependent', deps: [task.id] }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? 'tab_coord:leaf_coord' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + await call('orchestration.send', { + from: 'term_coord', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + }) + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getTask(dependent.id)?.status).toBe('pending') + }) + + it('does not replace a foreign sender pane with its claimed assignee handle pane', async () => { + setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + const result = (await call('orchestration.send', { + from: 'term_worker', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + senderPaneKey: 'tab_foreign:leaf_foreign', + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + })) as { + message: { id: string; type: string; subject: string } + lifecycle: { action: string; code: string; reason: string } + } + + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(result.lifecycle).toMatchObject({ + action: 'rejected', + code: 'sender_not_assignee', + reason: expect.stringContaining('expected handle term_worker') + }) + expect(result.message).toMatchObject({ + type: 'worker_done', + subject: 'Rejected worker_done: Done' + }) + expect(db.getUnreadMessages('term_coord')).toEqual([ + expect.objectContaining({ id: result.message.id, type: 'worker_done' }) + ]) + expect(runtime.notifyMessageArrived).toHaveBeenCalledWith('term_coord', 'worker_done') + }) + it('does not wake waiters for a heartbeat suppressed at send time', async () => { setup() const task = db.createTask({ spec: 'work' }) @@ -512,9 +606,9 @@ describe('orchestration RPC methods', () => { }) describe('orchestration.check', () => { - function createDispatchedTask(assigneeHandle = 'term_worker') { + function createDispatchedTask(assigneeHandle = 'term_worker', assigneePaneKey?: string) { const task = db.createTask({ spec: 'manual check work' }) - const dispatch = db.createDispatchContext(task.id, assigneeHandle) + const dispatch = db.createDispatchContext(task.id, assigneeHandle, assigneePaneKey) return { task, dispatch } } @@ -524,6 +618,7 @@ describe('orchestration RPC methods', () => { taskId?: string dispatchId?: string filesModified?: string[] + senderPaneKey?: string }): void { const payload: Record = {} if (params.taskId !== undefined) { @@ -541,7 +636,8 @@ describe('orchestration RPC methods', () => { to: params.to ?? 'term_coord', subject: 'Done', type: 'worker_done', - payload: JSON.stringify(payload) + payload: JSON.stringify(payload), + senderPaneKey: params.senderPaneKey }) } @@ -670,11 +766,13 @@ describe('orchestration RPC methods', () => { it('completes worker_done by payload IDs when the sender handle changed', async () => { setup() - const { task, dispatch } = createDispatchedTask('term_owner') + const leafId = '11111111-1111-4111-8111-111111111111' + const { task, dispatch } = createDispatchedTask('term_owner', `tab_before:${leafId}`) insertWorkerDone({ from: 'term_reminted', taskId: task.id, - dispatchId: dispatch.id + dispatchId: dispatch.id, + senderPaneKey: `tab_after:${leafId}` }) const result = (await call('orchestration.check', { @@ -710,6 +808,34 @@ describe('orchestration RPC methods', () => { expect(db.getDispatchContextById(activeDispatch.id)?.status).toBe('dispatched') }) + it('returns a persisted foreign completion as a rejection diagnostic', async () => { + setup() + const { task, dispatch } = createDispatchedTask('term_worker', 'tab_worker:leaf_worker') + insertWorkerDone({ + from: 'term_foreign', + taskId: task.id, + dispatchId: dispatch.id, + senderPaneKey: 'tab_foreign:leaf_foreign' + }) + + const result = (await call('orchestration.check', { + terminal: 'term_coord', + types: 'worker_done' + })) as { count: number; messages: { type: string; subject: string; body: string }[] } + + expect(result).toMatchObject({ + count: 1, + messages: [ + { + type: 'worker_done', + subject: 'Rejected worker_done: Done', + body: expect.stringContaining('expected handle term_worker') + } + ] + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + it('records heartbeat returned by unread manual check', async () => { setup() const { task, dispatch } = createDispatchedTask() diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 597ae99b7fb..9caaf1ac57a 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -209,6 +209,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() const from = params.from ?? 'unknown' + // Why: older live shells may lack ORCA_PANE_KEY, but the runtime still + // knows the pane behind their resolved handle; persist that authority. + const senderPaneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(from) ?? undefined if (!isGroupAddress(params.to)) { // Point-to-point — existing single-recipient behavior @@ -221,7 +224,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ priority: params.priority as MessagePriority, threadId: params.threadId, payload: params.payload, - senderPaneKey: params.senderPaneKey + senderPaneKey }) // Why: worker_done/heartbeat sent via `send` must release the dispatch // lock before waking recipients — a coordinator woken by delivery may @@ -234,6 +237,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (reconciled.action === 'suppressed') { return { message: msg } } + if (reconciled.action === 'rejected') { + const rejection = db.getMessageById(msg.id) ?? msg + runtime.deliverPendingMessagesForHandle(params.to) + runtime.notifyMessageArrived(params.to, rejection.type) + return { message: rejection, lifecycle: reconciled } + } } runtime.deliverPendingMessagesForHandle(params.to) runtime.notifyMessageArrived(params.to, msg.type) @@ -263,7 +272,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ priority: params.priority as MessagePriority, threadId, payload: params.payload, - senderPaneKey: params.senderPaneKey + senderPaneKey }) ) for (const message of messages) { @@ -304,22 +313,26 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ ? db.getAllMessagesForHandle(handle, undefined, typeFilter) : db.getUnreadMessages(handle, typeFilter) + let visibleMessages = messages if (consumeUnread && messages.length > 0) { // Why: manual coordinators can consume lifecycle messages before // the coordinator loop sees them, but unread `check` is still an // authoritative read path for worker_done/heartbeat. - for (const message of messages) { - reconcileLifecycleMessage(db, message) - } + visibleMessages = messages.map((message) => { + const reconciled = reconcileLifecycleMessage(db, message) + return reconciled.action === 'rejected' + ? (db.getMessageById(message.id) ?? message) + : message + }) db.markAsRead(messages.map((m) => m.id)) } if (params.inject) { - const formatted = messages.map(formatMessageBanner).join('\n\n') - return { messages, formatted, count: messages.length } + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { messages: visibleMessages, formatted, count: visibleMessages.length } } - return { messages, count: messages.length } + return { messages: visibleMessages, count: visibleMessages.length } } if (signal?.aborted) { @@ -481,6 +494,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: 'ctx_dryrun', taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', + workerHandle: params.to ?? 'worker', devMode: params.devMode }) return { dispatch: null, injected: false, dryRun: true, preamble } @@ -525,6 +539,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: ctx.id, taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', + workerHandle: to, devMode: params.devMode }) @@ -576,6 +591,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ dispatchId: ctx?.id ?? 'ctx_preview', taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', + workerHandle: ctx?.assignee_handle ?? 'worker', devMode: params.devMode }) return { dispatch: ctx ?? null, preamble } diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 8cc46e8b151..244ae80a48c 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -108,11 +108,12 @@ describe('isRelayAlreadyInstalled', () => { ).rejects.toBe(sessionLimitError) }) - it('checks for relay.js AND .install-complete in addition to the dir', async () => { + it('checks for both relay process artifacts and .install-complete', async () => { mockExec.mockResolvedValueOnce('OK') await isRelayAlreadyInstalled(conn, '/r') const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(cmd).toContain('relay.js') + expect(cmd).toContain('relay-watcher.js') expect(cmd).toContain('.install-complete') }) }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 5ebac3a07fc..a669f0b7875 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -126,10 +126,9 @@ export function computeRemoteRelayDir( /** * Probe whether a fully-installed relay already exists at remoteRelayDir. * - * "Fully installed" means: the directory exists, contains relay.js, AND - * contains the .install-complete sentinel written at the end of a successful - * install. A directory missing .install-complete is either mid-install (lock - * held) or a crashed-install partial — either way we re-run the deploy. + * "Fully installed" means: the directory contains relay.js, its isolated + * relay-watcher.js child, and the .install-complete sentinel written at the + * end of a successful install. Missing artifacts force a complete re-deploy. */ export async function isRelayAlreadyInstalled( conn: SshConnection, diff --git a/src/main/ssh/ssh-remote-linear-argument-error.ts b/src/main/ssh/ssh-remote-cli-argument-error.ts similarity index 100% rename from src/main/ssh/ssh-remote-linear-argument-error.ts rename to src/main/ssh/ssh-remote-cli-argument-error.ts diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 01ab8b266a7..339f4aef594 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -59,11 +59,13 @@ export function probeRelayInstalledCommand( remoteRelayDir: string ): string { const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js') + const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js') const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete') if (!isWindowsRemoteHost(host)) { return ( `test -d ${shellEscape(remoteRelayDir)} ` + `&& test -f ${shellEscape(relayJs)} ` + + `&& test -f ${shellEscape(relayWatcherJs)} ` + `&& test -f ${shellEscape(installComplete)} ` + `&& echo OK || echo MISSING` ) @@ -72,8 +74,9 @@ export function probeRelayInstalledCommand( [ `$dir = ${powerShellLiteral(remoteRelayDir)}`, `$relay = ${powerShellLiteral(relayJs)}`, + `$watcher = ${powerShellLiteral(relayWatcherJs)}`, `$complete = ${powerShellLiteral(installComplete)}`, - "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" + "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" ].join('; ') ) } diff --git a/src/main/ssh/ssh-remote-linear-cli.ts b/src/main/ssh/ssh-remote-linear-cli.ts index 3367c217256..4a5c5c4b416 100644 --- a/src/main/ssh/ssh-remote-linear-cli.ts +++ b/src/main/ssh/ssh-remote-linear-cli.ts @@ -2,14 +2,12 @@ import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import { getRemoteLinearReadHelp } from './ssh-remote-linear-read-help' import { tryDispatchRemoteLinearReadCli } from './ssh-remote-linear-read-cli' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-linear-argument-error' +import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error' import { getRemoteLinearWriteHelp, tryDispatchRemoteLinearWriteCli } from './ssh-remote-linear-write-cli' -export { RemoteCliArgumentError } - export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null { const helpPath = remoteLinearHelpPath(parsed) if (!helpPath) { diff --git a/src/main/ssh/ssh-remote-linear-read-cli.ts b/src/main/ssh/ssh-remote-linear-read-cli.ts index 82a6fedb2ad..7e64081c5c0 100644 --- a/src/main/ssh/ssh-remote-linear-read-cli.ts +++ b/src/main/ssh/ssh-remote-linear-read-cli.ts @@ -6,7 +6,7 @@ import { } from '../../shared/linear-agent-access' import type { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' -import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-linear-argument-error' +import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' import { LINEAR_ISSUE_FLAGS, diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index f54c2157c55..1741772d789 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -11,7 +11,8 @@ vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/host/user-data' })) -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { OrchestrationDb } from '../runtime/orchestration/db' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' @@ -91,6 +92,7 @@ describe('runRemoteOrcaCli', () => { liveLeafCount: 1 }), getOrchestrationDb: () => db, + getTerminalPaneKey: () => null, deliverPendingMessagesForHandle: vi.fn(), notifyMessageArrived: vi.fn(), linearIssueContext: vi.fn(async (request: unknown) => ({ @@ -147,6 +149,191 @@ describe('runRemoteOrcaCli', () => { expect(db.getUnreadMessages('term_windows')[0]?.from_handle).toBe('term_ssh') }) + it('forwards remote pane identity through the legacy orchestration fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_PANE_KEY: 'tab_ssh:leaf_ssh' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.insertMessage).toHaveBeenCalledWith( + expect.objectContaining({ senderPaneKey: 'tab_ssh:leaf_ssh' }) + ) + }) + + it('returns a non-zero status for lifecycle rejection through the legacy fallback', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + const task = db.createTask({ spec: 'remote work' }) + const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + + try { + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--from', + 'term_ssh', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--payload', + JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + '--json' + ], + cwd: '/home/alice/repo', + env: { ORCA_PANE_KEY: 'tab_foreign:leaf_foreign' } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + result: { + message: { type: 'worker_done', subject: 'Rejected worker_done: Done' }, + lifecycle: { action: 'rejected', code: 'sender_not_assignee' } + } + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + } finally { + db.close() + } + }) + + it('preserves structured lifecycle payload flags through the legacy fallback', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + const task = db.createTask({ spec: 'remote work' }) + const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + + try { + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--task-id', + task.id, + '--dispatch-id', + dispatch.id, + '--files-modified', + 'src/a.ts, src/b.ts', + '--json' + ], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_PANE_KEY: 'tab_owner:leaf_owner' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.getTask(task.id)).toMatchObject({ + status: 'completed', + result: expect.stringContaining('src/a.ts') + }) + } finally { + db.close() + } + }) + + it('rejects identity-less lifecycle sends in the legacy fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to', + 'term_coord', + '--subject', + 'Done', + '--type', + 'worker_done', + '--json' + ], + cwd: '/home/alice/repo', + env: {} + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: 'no_active_sender_terminal' } + }) + expect(db.insertMessage).not.toHaveBeenCalled() + }) + + it('rejects mixed raw and structured payload flags in the legacy fallback', async () => { + const { runtime, db } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--from', + 'term_ssh', + '--to', + 'term_coord', + '--subject', + 'Done', + '--payload', + '{"taskId":"task_1"}', + '--task-id', + 'task_1', + '--json' + ], + cwd: '/home/alice/repo', + env: {} + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument', message: expect.stringContaining('structured payload') } + }) + expect(db.insertMessage).not.toHaveBeenCalled() + }) + it('accepts equals-style orchestration flags in the remote shim', async () => { const { runtime, db } = createRuntime() diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index d555b5c2707..205723321da 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -10,19 +10,16 @@ import { type RemoteOrcaCliRequest, type RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' +import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error' +import { getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli' import { - RemoteCliArgumentError, - getRemoteLinearHelp, - tryDispatchRemoteLinearCli -} from './ssh-remote-linear-cli' + getRemoteOrchestrationPayload, + hasRemoteLifecycleRejection, + resolveRemoteOrchestrationSender +} from './ssh-remote-orchestration-send' export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' -type ParsedRemoteCli = { - commandPath: string[] - flags: Map -} - // Why: these commands run a foreground/interactive process attached to the // caller's TTY (or a local tmux pane), which a buffered one-shot relay bridge // cannot host. Everything else routes through the full host CLI. @@ -116,7 +113,9 @@ async function runLegacyRemoteOrcaCli( return { stdout: formatted.stdout, stderr: formatted.stderr, - exitCode: response.ok ? 0 : 1 + // Why: the legacy SSH bridge bypasses the local CLI handler that turns + // a persisted lifecycle rejection into an unsuccessful command. + exitCode: response.ok && !hasRemoteLifecycleRejection(response.result) ? 0 : 1 } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -174,17 +173,22 @@ async function dispatchRemoteCli( worktree: optionalString(parsed.flags, 'worktree'), limit: optionalNumber(parsed.flags, 'limit') }) - case 'orchestration send': + case 'orchestration send': { + const type = optionalString(parsed.flags, 'type') return await call(dispatcher, 'orchestration.send', { - from: resolveHandle(parsed.flags, env, 'from'), + from: resolveRemoteOrchestrationSender(parsed.flags, env, type), to: requiredString(parsed.flags, 'to'), subject: requiredString(parsed.flags, 'subject'), body: optionalString(parsed.flags, 'body'), - type: optionalString(parsed.flags, 'type'), + type, priority: optionalString(parsed.flags, 'priority'), threadId: optionalString(parsed.flags, 'thread-id'), - payload: optionalString(parsed.flags, 'payload') + payload: getRemoteOrchestrationPayload(parsed.flags), + // Why: the legacy in-process bridge must preserve the same pane + // authority as the full host CLI passthrough. + senderPaneKey: env.ORCA_PANE_KEY || undefined }) + } case 'orchestration check': return await call(dispatcher, 'orchestration.check', { terminal: resolveHandle(parsed.flags, env, 'terminal'), diff --git a/src/main/ssh/ssh-remote-orchestration-send.test.ts b/src/main/ssh/ssh-remote-orchestration-send.test.ts new file mode 100644 index 00000000000..6efd9a18891 --- /dev/null +++ b/src/main/ssh/ssh-remote-orchestration-send.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + getRemoteOrchestrationPayload, + hasRemoteLifecycleRejection, + resolveRemoteOrchestrationSender +} from './ssh-remote-orchestration-send' + +describe('remote orchestration send compatibility', () => { + it('recognizes only structured lifecycle rejections', () => { + expect(hasRemoteLifecycleRejection({ lifecycle: { action: 'rejected' } })).toBe(true) + expect(hasRemoteLifecycleRejection({ lifecycle: null })).toBe(false) + expect(hasRemoteLifecycleRejection({ lifecycle: { action: 'completed' } })).toBe(false) + expect(hasRemoteLifecycleRejection(null)).toBe(false) + }) + + it('prefers an explicit sender over the remote terminal environment', () => { + expect( + resolveRemoteOrchestrationSender( + new Map([['from', 'term_explicit']]), + { ORCA_TERMINAL_HANDLE: 'term_env' }, + 'worker_done' + ) + ).toBe('term_explicit') + }) + + it.each(['worker_done', 'heartbeat'])('fails closed for an identity-less %s', (type) => { + expect(() => resolveRemoteOrchestrationSender(new Map(), {}, type)).toThrowError( + expect.objectContaining({ code: 'no_active_sender_terminal' }) + ) + }) + + it('preserves the legacy unknown sender for non-lifecycle messages', () => { + expect(resolveRemoteOrchestrationSender(new Map(), {}, 'status')).toBe('unknown') + }) + + it('serializes every structured payload field with local CLI semantics', () => { + const payload = getRemoteOrchestrationPayload( + new Map([ + ['task-id', 'task_1'], + ['dispatch-id', 'ctx_1'], + ['files-modified', 'src/a.ts, src/b.ts,'], + ['report-path', 'report.md'], + ['phase', 'reviewing'] + ]) + ) + + expect(JSON.parse(payload ?? '{}')).toEqual({ + taskId: 'task_1', + dispatchId: 'ctx_1', + filesModified: ['src/a.ts', 'src/b.ts'], + reportPath: 'report.md', + phase: 'reviewing' + }) + }) + + it('passes through a raw payload when no structured fields are present', () => { + expect(getRemoteOrchestrationPayload(new Map([['payload', '{"ok":true}']]))).toBe('{"ok":true}') + }) + + it('rejects mixed raw and structured payloads', () => { + expect(() => + getRemoteOrchestrationPayload( + new Map([ + ['payload', '{"taskId":"task_1"}'], + ['task-id', 'task_1'] + ]) + ) + ).toThrowError(expect.objectContaining({ code: 'invalid_argument' })) + }) +}) diff --git a/src/main/ssh/ssh-remote-orchestration-send.ts b/src/main/ssh/ssh-remote-orchestration-send.ts new file mode 100644 index 00000000000..5e844a9c586 --- /dev/null +++ b/src/main/ssh/ssh-remote-orchestration-send.ts @@ -0,0 +1,83 @@ +import { RemoteCliArgumentError } from './ssh-remote-cli-argument-error' + +type RemoteFlags = Map + +export function hasRemoteLifecycleRejection(result: unknown): boolean { + if (!result || typeof result !== 'object') { + return false + } + const lifecycle = (result as { lifecycle?: unknown }).lifecycle + return ( + lifecycle !== null && + typeof lifecycle === 'object' && + (lifecycle as { action?: unknown }).action === 'rejected' + ) +} + +export function resolveRemoteOrchestrationSender( + flags: RemoteFlags, + env: Record, + type: string | undefined +): string { + const explicit = optionalString(flags, 'from') + const envHandle = env.ORCA_TERMINAL_HANDLE || undefined + if ((type === 'worker_done' || type === 'heartbeat') && !explicit && !envHandle) { + // Why: the fallback must not turn missing remote identity into the + // synthetic "unknown" handle for lifecycle authority decisions. + throw new RemoteCliArgumentError( + 'no_active_sender_terminal', + 'Could not determine the sender terminal for this orchestration command. ' + + 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' + ) + } + return explicit ?? envHandle ?? 'unknown' +} + +export function getRemoteOrchestrationPayload(flags: RemoteFlags): string | undefined { + const rawPayload = optionalString(flags, 'payload') + const taskId = optionalString(flags, 'task-id') + const dispatchId = optionalString(flags, 'dispatch-id') + const filesModified = optionalString(flags, 'files-modified') + const reportPath = optionalString(flags, 'report-path') + const phase = optionalString(flags, 'phase') + const hasStructuredPayload = [taskId, dispatchId, filesModified, reportPath, phase].some( + (value) => value !== undefined + ) + if (!hasStructuredPayload) { + return rawPayload + } + if (rawPayload !== undefined) { + throw new RemoteCliArgumentError( + 'invalid_argument', + 'Use either --payload or structured payload flags, not both.' + ) + } + + // Why: the fallback receives the same preamble commands as the full CLI; + // preserving these flags keeps lifecycle payloads valid over broken installs. + const payload: Record = {} + if (taskId) { + payload.taskId = taskId + } + if (dispatchId) { + payload.dispatchId = dispatchId + } + if (filesModified) { + payload.filesModified = filesModified + .split(',') + .map((file) => file.trim()) + .filter(Boolean) + } + if (reportPath) { + payload.reportPath = reportPath + } + if (phase) { + payload.phase = phase + } + return JSON.stringify(payload) +} + +function optionalString(flags: RemoteFlags, name: string): string | undefined { + const value = flags.get(name) + return typeof value === 'string' && value.length > 0 ? value : undefined +} diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 7939af31aae..d99177285af 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -24,6 +24,31 @@ describe('desktop startup ordering', () => { expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0) }) + it('shows the desktop window without waiting for WSL registration reconciliation', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") + const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart) + const barrier = source.slice(barrierStart, barrierEnd) + const reconciliationStart = source.indexOf( + 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + ) + const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) + const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) + const serveEnd = source.indexOf('return', serveReady) + const desktopWindowStart = source.indexOf('Promise.resolve(openMainWindow())') + const serveStartup = source.slice(serveStart, serveEnd) + const desktopStartup = source.slice(serveEnd, desktopWindowStart) + + expect(reconciliationStart).toBeGreaterThanOrEqual(0) + expect(serveStart).toBeGreaterThan(reconciliationStart) + expect(serveEnd).toBeGreaterThan(serveStart) + expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) + expect(serveStartup).toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(barrier).toContain('managedWslCliStartupBarrierReady') + expect(barrier).not.toContain('managedWslCliReconciliationReady') + }) + it('does not run the rate-limit quota fetch before the first window can show results', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const attachIndex = source.indexOf('rateLimits.attach(window)') diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts new file mode 100644 index 00000000000..e59201e6a02 --- /dev/null +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { + WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS, + createWslCliReconciliationStartupBarrier +} from './wsl-cli-reconciliation-startup-barrier' + +describe('createWslCliReconciliationStartupBarrier', () => { + it('resolves as soon as reconciliation finishes', async () => { + vi.useFakeTimers() + let resolveReconciliation!: () => void + + try { + const reconciliation = new Promise((resolve) => { + resolveReconciliation = resolve + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let barrierSettled = false + void barrier.then(() => { + barrierSettled = true + }) + + await vi.advanceTimersByTimeAsync(1) + expect(barrierSettled).toBe(false) + + resolveReconciliation() + await expect(barrier).resolves.toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('fails open when reconciliation exceeds the startup budget', async () => { + vi.useFakeTimers() + let resolveReconciliation!: () => void + + try { + const reconciliation = new Promise((resolve) => { + resolveReconciliation = resolve + }) + const barrier = createWslCliReconciliationStartupBarrier(reconciliation) + let barrierSettled = false + void barrier.then(() => { + barrierSettled = true + }) + + await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1) + expect(barrierSettled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(barrier).resolves.toBeUndefined() + resolveReconciliation() + await reconciliation + } finally { + vi.useRealTimers() + } + }) + + it('leaves reconciliation running after the startup budget expires', async () => { + vi.useFakeTimers() + let resolveWork!: () => void + let completed = false + + try { + const work = new Promise((resolve) => { + resolveWork = resolve + }).then(() => { + completed = true + }) + const barrier = createWslCliReconciliationStartupBarrier(work, { timeoutMs: 10 }) + + await vi.advanceTimersByTimeAsync(10) + await expect(barrier).resolves.toBeUndefined() + expect(completed).toBe(false) + + resolveWork() + await work + expect(completed).toBe(true) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts new file mode 100644 index 00000000000..5f70ad7eeb0 --- /dev/null +++ b/src/main/startup/wsl-cli-reconciliation-startup-barrier.ts @@ -0,0 +1,33 @@ +export const WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS = 2_000 + +type WslCliReconciliationStartupBarrierOptions = { + timeoutMs?: number +} + +/** + * Briefly gates restored terminals while managed WSL registrations reconcile. + */ +export function createWslCliReconciliationStartupBarrier( + reconciliation: Promise, + options: WslCliReconciliationStartupBarrierOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS + let timeout: ReturnType | null = null + const settled = reconciliation + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + if (timeout) { + clearTimeout(timeout) + } + }) + + // Why: reconciliation may outlive a slow or unavailable WSL distro; restored + // terminals should wait briefly without turning WSL discovery into an app hang. + return Promise.race([ + settled, + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs) + }) + ]) +} diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index fdb5d01a22c..597b1e795f4 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type * as childProcess from 'node:child_process' -const { execFileSyncMock } = vi.hoisted(() => ({ +const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), execFileSyncMock: vi.fn() })) @@ -9,11 +10,21 @@ vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + execFile: execFileMock, execFileSync: execFileSyncMock } }) -import { toLinuxPath, toWindowsWslPath, parseWslPath, wslUncDirectoryExists } from './wsl' +import { + _resetWslCachesForTests, + getCachedWslDistros, + listWslDistros, + listWslDistrosAsync, + parseWslPath, + toLinuxPath, + toWindowsWslPath, + wslUncDirectoryExists +} from './wsl' function withPlatform(value: NodeJS.Platform, fn: () => T): T { const original = process.platform @@ -25,6 +36,70 @@ function withPlatform(value: NodeJS.Platform, fn: () => T): T { } } +async function withPlatformAsync(value: NodeJS.Platform, fn: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('WSL distro discovery cache', () => { + afterEach(() => { + execFileMock.mockReset() + execFileSyncMock.mockReset() + _resetWslCachesForTests() + }) + + it('retries asynchronous discovery after a transient wsl.exe failure', async () => { + vi.useFakeTimers() + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(new Error('transient failure'), '') + }) + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\n') + }) + + try { + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + expect(getCachedWslDistros()).toBeNull() + // Brief negative caching bounds the wsl.exe spawn rate between retries. + await expect(listWslDistrosAsync()).resolves.toEqual([]) + expect(execFileMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(15_000) + await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + it('retries synchronous discovery after a transient wsl.exe failure', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw new Error('transient failure') + }) + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toBeNull() + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) +}) + describe('wsl path helpers', () => { it('parses WSL UNC paths on Windows', () => { const originalPlatform = process.platform diff --git a/src/main/wsl.ts b/src/main/wsl.ts index d668a064f66..1d27b694d2c 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -113,6 +113,12 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string { const wslHomeCache = new Map() let wslDistroCache: string[] | null = null +// Why: a wsl.exe failure must stay retryable (a transient error would +// otherwise hide every distro until restart), but repeated failures cannot +// re-spawn a blocking wsl.exe on every caller; brief negative caching bounds +// the spawn rate on machines where WSL is absent or persistently broken. +const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000 +let wslDistroListFailedUntilMs = 0 function normalizeWslListOutput(output: string): string[] { // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through @@ -138,6 +144,10 @@ export function listWslDistros(): string[] { return wslDistroCache } + if (Date.now() < wslDistroListFailedUntilMs) { + return [] + } + try { const output = execFileSync('wsl.exe', ['--list', '--quiet'], { encoding: 'utf-8', @@ -147,8 +157,8 @@ export function listWslDistros(): string[] { wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) return wslDistroCache } catch { - wslDistroCache = [] - return wslDistroCache + wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS + return [] } } @@ -162,13 +172,17 @@ export async function listWslDistrosAsync(): Promise { return wslDistroCache } + if (Date.now() < wslDistroListFailedUntilMs) { + return [] + } + try { const output = await execFileUtf8('wsl.exe', ['--list', '--quiet']) wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) return wslDistroCache } catch { - wslDistroCache = [] - return wslDistroCache + wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS + return [] } } @@ -279,6 +293,7 @@ export function getCachedWslAvailability(): boolean | null { export function _resetWslCachesForTests(): void { wslHomeCache.clear() wslDistroCache = null + wslDistroListFailedUntilMs = 0 wslAvailableCache = null } diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 7d5354cf47a..63e399072e9 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises' import * as path from 'node:path' import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' +import { subscribeWithInProcessWatcher } from '../main/ipc/parcel-watcher-in-process-fallback' const { mockSubscribe } = vi.hoisted(() => ({ mockSubscribe: vi.fn() @@ -121,7 +122,11 @@ describe('FsHandler', () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-')) dispatcher = createMockDispatcher() const ctx = new RelayContext() - handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx) + handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx, { + dispose: vi.fn(), + forgetRoot: vi.fn(), + subscribe: subscribeWithInProcessWatcher + }) }) afterEach(async () => { diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 6074fad2e2b..38f0dec9b5d 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: relay filesystem request handling shares - path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */ + path expansion, file IO, search, streaming reads, and Space scans. */ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises' import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' @@ -34,17 +34,8 @@ import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' -import { - WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOptions -} from '../main/ipc/filesystem-watcher-ignore' - -type WatchState = { - rootPath: string - unwatchFn: (() => void) | null - setupPromise: Promise | null - clients: Map boolean> -} +import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' +import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' async function isDirectoryEntry( dirPath: string, @@ -85,15 +76,19 @@ function fileStatFromLstat(stats: Awaited>) { export class FsHandler { private dispatcher: RelayDispatcher - private watches = new Map() + private watchRegistry: RelayFilesystemWatchRegistry private streamRegistry = new RelayStreamRegistry() private listFilesScans = new ListFilesScanCoordinator() - constructor(dispatcher: RelayDispatcher, _context: RelayContext) { + constructor( + dispatcher: RelayDispatcher, + _context: RelayContext, + watcherPool?: RelayWatcherProcessPool + ) { this.dispatcher = dispatcher + this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool) this.registerHandlers() - this.dispatcher.onClientDetached?.((clientId) => { - this.releaseClientWatches(clientId) + this.dispatcher.onClientDetached?.(() => { // Why: a detached client's fs.streamAck frames will never arrive; wake // any pump parked on the ack window so it re-checks staleness and exits // instead of stranding its open file handle. @@ -122,8 +117,12 @@ export class FsHandler { this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) - this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) - this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context)) + this.dispatcher.onRequest('fs.watch', (p, context) => + this.watchRegistry.watch(expandTilde(p.rootPath as string), context) + ) + this.dispatcher.onNotification('fs.unwatch', (p, context) => + this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context) + ) this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) } @@ -414,125 +413,8 @@ export class FsHandler { return scanWorkspaceSpaceDirectory(rootPath, context) } - private async watch(params: Record, context?: RequestContext) { - const rootPath = expandTilde(params.rootPath as string) - - this.releaseStaleWatches() - - const existing = this.watches.get(rootPath) - if (existing) { - if ([...existing.clients.values()].some((isStale) => !isStale())) { - existing.clients.set(context?.clientId ?? 0, context?.isStale ?? (() => false)) - if (existing.setupPromise) { - await existing.setupPromise - } - return - } - existing.unwatchFn?.() - this.watches.delete(rootPath) - } - - if (this.watches.size >= 20) { - throw new Error('Maximum number of file watchers reached') - } - - const watchState: WatchState = { - rootPath, - unwatchFn: null, - setupPromise: null, - clients: new Map([[context?.clientId ?? 0, context?.isStale ?? (() => false)]]) - } - this.watches.set(rootPath, watchState) - - const setupPromise = (async () => { - const watcher = await import('@parcel/watcher') - const subscription = await watcher.subscribe( - rootPath, - (err, events) => { - if (err) { - this.dispatcher.notify('fs.changed', { - events: [{ kind: 'overflow', absolutePath: rootPath }] - }) - return - } - const mapped = events.map((evt) => ({ - kind: evt.type, - absolutePath: evt.path - })) - this.dispatcher.notify('fs.changed', { events: mapped }) - }, - // Why: align remote watchers with the shared nested exclusion so - // generated trees neither exhaust inotify nor trigger slow glob regexes. - buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) - ) - watchState.unwatchFn = () => { - void subscription.unsubscribe() - } - if ( - [...watchState.clients.values()].every((isStale) => isStale()) || - this.watches.get(rootPath) !== watchState - ) { - // Why: if the only requesting client reconnects while watcher setup is - // in flight, no client can later balance it with fs.unwatch. Tear down - // only this request's subscription so a newer replacement watch for the - // same root is not removed. - void subscription.unsubscribe() - if (this.watches.get(rootPath) === watchState) { - this.watches.delete(rootPath) - } - } - })() - watchState.setupPromise = setupPromise - - try { - await setupPromise - } catch { - if (this.watches.get(rootPath) === watchState) { - this.watches.delete(rootPath) - } - // @parcel/watcher not available -- polling fallback would go here - process.stderr.write('[relay] File watcher not available, fs.changed events disabled\n') - } - } - - private unwatch(params: Record, context?: RequestContext): void { - const rootPath = expandTilde(params.rootPath as string) - const state = this.watches.get(rootPath) - if (state) { - this.releaseWatchClient(rootPath, state, context?.clientId ?? 0) - } - } - - private releaseClientWatches(clientId: number): void { - for (const [rootPath, state] of this.watches) { - this.releaseWatchClient(rootPath, state, clientId) - } - } - - private releaseStaleWatches(): void { - for (const [rootPath, state] of this.watches) { - if ([...state.clients.values()].some((isStale) => !isStale())) { - continue - } - state.unwatchFn?.() - this.watches.delete(rootPath) - } - } - - private releaseWatchClient(rootPath: string, state: WatchState, clientId: number): void { - state.clients.delete(clientId) - if (state.clients.size > 0) { - return - } - state.unwatchFn?.() - this.watches.delete(rootPath) - } - dispose(): void { - for (const [, state] of this.watches) { - state.unwatchFn?.() - } - this.watches.clear() + this.watchRegistry.dispose() void this.streamRegistry.disposeAll() } } diff --git a/src/relay/git-handler-staging.test.ts b/src/relay/git-handler-staging.test.ts index d4469f37cda..b113afd30c4 100644 --- a/src/relay/git-handler-staging.test.ts +++ b/src/relay/git-handler-staging.test.ts @@ -1,5 +1,5 @@ /** - * Tests for GitHandler commit and bulk-staging operations. + * Tests for GitHandler commit and staging operations. * * Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit. */ @@ -19,6 +19,32 @@ import { type RelayDispatcher } from './git-handler-test-setup' +const PATHSPEC_SELECTED_FILE = '[k]eep.log' +const PATHSPEC_MATCHING_FILE = 'keep.log' +const PATHSPEC_MUTATION_CASES = [ + { + mode: 'single-file', + stageMethod: 'git.stage', + unstageMethod: 'git.unstage', + selection: { filePath: PATHSPEC_SELECTED_FILE } + }, + { + mode: 'bulk', + stageMethod: 'git.bulkStage', + unstageMethod: 'git.bulkUnstage', + selection: { filePaths: [PATHSPEC_SELECTED_FILE] } + } +] as const + +function createPathspecCollisionChanges(dir: string): void { + gitInit(dir) + writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected') + writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching') + gitCommit(dir, 'initial') + writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected modified') + writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching modified') +} + describe('GitHandler — commit & staging', () => { let dispatcher: MockDispatcher let tmpDir: string @@ -77,7 +103,38 @@ describe('GitHandler — commit & staging', () => { }) }) - describe('bulkStage and bulkUnstage', () => { + describe('stage and unstage', () => { + it.each(PATHSPEC_MUTATION_CASES)( + 'treats $mode stage paths with Git glob characters as literals', + async ({ stageMethod, selection }) => { + createPathspecCollisionChanges(tmpDir) + + await dispatcher.callRequest(stageMethod, { worktreePath: tmpDir, ...selection }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe(PATHSPEC_SELECTED_FILE) + } + ) + + it.each(PATHSPEC_MUTATION_CASES)( + 'treats $mode unstage paths with Git glob characters as literals', + async ({ unstageMethod, selection }) => { + createPathspecCollisionChanges(tmpDir) + execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest(unstageMethod, { worktreePath: tmpDir, ...selection }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe(PATHSPEC_MATCHING_FILE) + } + ) + it('stages multiple files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'a.txt'), 'a') diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 1ae68d3ce88..d674af9949e 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -1192,7 +1192,7 @@ describe('GitHandler', () => { await Promise.all([first, second]) expect(gitBufferSpy).toHaveBeenCalledTimes(2) - expect(gitSpy).toHaveBeenCalledWith(['add', '--', 'src/file.ts'], tmpDir) + expect(gitSpy).toHaveBeenCalledWith(['add', '--', ':(literal)src/file.ts'], tmpDir) const submodulePathReads = gitSpy.mock.calls.filter( ([args]) => args[0] === 'config' && args.includes('.gitmodules') ) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index d21dcf3cce9..c6709cd3f2a 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -488,7 +488,7 @@ export class GitHandler { const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { - await this.git(['add', '--', filePath], worktreePath) + await this.git(['add', '--', this.literalPathspec(filePath)], worktreePath) } finally { this.clearGitMutationReadCaches() } @@ -512,7 +512,7 @@ export class GitHandler { const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { - await this.git(['restore', '--staged', '--', filePath], worktreePath) + await this.git(['restore', '--staged', '--', this.literalPathspec(filePath)], worktreePath) } finally { this.clearGitMutationReadCaches() } @@ -525,7 +525,10 @@ export class GitHandler { try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git(['add', '--', ...chunk], worktreePath) + await this.git( + ['add', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))], + worktreePath + ) } } finally { this.clearGitMutationReadCaches() @@ -539,7 +542,10 @@ export class GitHandler { try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git(['restore', '--staged', '--', ...chunk], worktreePath) + await this.git( + ['restore', '--staged', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))], + worktreePath + ) } } finally { this.clearGitMutationReadCaches() diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts new file mode 100644 index 00000000000..5f1e46eecdd --- /dev/null +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' +import type { + WatcherProcessCallback, + WatcherProcessHooks, + WatcherProcessSubscription +} from '../main/ipc/parcel-watcher-process-subscription' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' +import { createRelayWatcherProcessPool } from './relay-watcher-process-pool' + +type InstalledWatch = { + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + unsubscribe: ReturnType Promise>> +} + +class FakeWatcherPool { + readonly installed: InstalledWatch[] = [] + readonly dispose = vi.fn() + readonly forgetRoot = vi.fn() + + async subscribe( + _rootPath: string, + callback: WatcherProcessCallback, + _options: object, + hooks: WatcherProcessHooks + ): Promise { + const unsubscribe = vi.fn(async () => undefined) + this.installed.push({ callback, hooks, unsubscribe }) + return { unsubscribe } + } +} + +function createDispatcher() { + const detached = new Set<(clientId: number) => void>() + return { + notify: vi.fn(), + onClientDetached: vi.fn((listener: (clientId: number) => void) => { + detached.add(listener) + return () => detached.delete(listener) + }) + } +} + +function context(clientId: number): RequestContext { + return { clientId, isStale: () => false } +} + +describe('RelayFilesystemWatchRegistry', () => { + let dispatcher: ReturnType + let pool: FakeWatcherPool + let registry: RelayFilesystemWatchRegistry + + beforeEach(() => { + dispatcher = createDispatcher() + pool = new FakeWatcherPool() + registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool) + }) + + it('emits overflow around child replacement and resumes ordered event delivery', async () => { + await registry.watch('/repo', context(1)) + const first = pool.installed[0] + + first.callback(null, [{ type: 'create', path: '/repo/before.txt' }]) + first.hooks.onInterruption?.() + first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }]) + + expect(dispatcher.notify.mock.calls).toEqual([ + ['fs.changed', { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }], + ['fs.changed', { events: [{ kind: 'overflow', absolutePath: '/repo' }] }], + ['fs.changed', { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] }] + ]) + }) + + it('moves a terminal shard failure into recovery without dropping shared clients', async () => { + await registry.watch('/repo', context(1)) + await registry.watch('/repo', context(2)) + const first = pool.installed[0] + first.hooks.onTerminalError?.( + new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + ) + await Promise.resolve() + + expect(pool.installed).toHaveLength(2) + pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }]) + expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'fs.changed', { + events: [{ kind: 'overflow', absolutePath: '/repo' }] + }) + expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'fs.changed', { + events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }] + }) + + registry.unwatch('/repo', context(1)) + expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled() + registry.unwatch('/repo', context(2)) + expect(pool.installed[1].unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('aborts a pending crawl only after the last same-root client leaves', async () => { + let sharedSignal: AbortSignal | undefined + let rejectSubscribe: ((error: Error) => void) | undefined + vi.spyOn(pool, 'subscribe').mockImplementation( + (_rootPath, _callback, _options, hooks): Promise => + new Promise((_resolve, reject) => { + sharedSignal = hooks.signal + rejectSubscribe = reject + hooks.signal?.addEventListener( + 'abort', + () => + reject( + new WatcherProcessFailure( + 'file watcher subscription aborted', + 'subscription', + 'subscribe_aborted' + ) + ), + { once: true } + ) + }) + ) + const firstAbort = new AbortController() + const secondAbort = new AbortController() + const first = registry.watch('/repo', { + ...context(1), + signal: firstAbort.signal + }) + const second = registry.watch('/repo', { + ...context(2), + signal: secondAbort.signal + }) + + firstAbort.abort() + await first + expect(sharedSignal?.aborted).toBe(false) + + secondAbort.abort() + await second + expect(sharedSignal?.aborted).toBe(true) + expect(rejectSubscribe).toBeDefined() + }) +}) + +describe('createRelayWatcherProcessPool', () => { + it('fails closed instead of loading the native watcher in the relay process', async () => { + const previousVitest = process.env.VITEST + process.env.VITEST = 'true' + const pool = createRelayWatcherProcessPool( + join(tmpdir(), `missing-relay-watcher-${process.pid}.js`) + ) + try { + await expect(pool.subscribe('/repo', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'entry_missing' + }) + } finally { + pool.dispose() + if (previousVitest === undefined) { + delete process.env.VITEST + } else { + process.env.VITEST = previousVitest + } + } + }) +}) diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts new file mode 100644 index 00000000000..a1ccda05826 --- /dev/null +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -0,0 +1,312 @@ +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' +import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' +import type { + WatcherProcessEvent, + WatcherProcessSubscription +} from '../main/ipc/parcel-watcher-process' +import { + WATCHER_IGNORE_DIRS, + buildParcelWatcherIgnoreOptions +} from '../main/ipc/filesystem-watcher-ignore' +import { + createRelayWatcherProcessPool, + type RelayWatcherProcessPool +} from './relay-watcher-process-pool' + +const MAX_RELAY_WATCH_ROOTS = 20 +const RELAY_WATCH_CRAWL_TIMEOUT_MS = 60_000 +const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) + +type RelayWatchState = { + rootPath: string + clients: Map boolean> + setupPromise: Promise + subscription: WatcherProcessSubscription | null + abortController: AbortController + generation: number + closed: boolean +} + +function overflowEvent(rootPath: string): Record { + return { events: [{ kind: 'overflow', absolutePath: rootPath }] } +} + +function createWatchAbortError(): Error { + const error = new Error('Request "fs.watch" was cancelled') + error.name = 'AbortError' + return error +} + +function shouldRetryInitialWatch(error: unknown): boolean { + return ( + isWatcherProcessFailure(error) && + error.code !== 'entry_missing' && + error.code !== 'subscribe_aborted' && + error.code !== 'supervisor_disposed' && + (error.scope === 'supervisor' || error.code === 'subscribe_timeout') + ) +} + +export class RelayFilesystemWatchRegistry { + private readonly watches = new Map() + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly watcherPool: RelayWatcherProcessPool = createRelayWatcherProcessPool() + ) { + this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId)) + } + + async watch(rootPath: string, context?: RequestContext): Promise { + this.releaseStaleWatches() + const clientId = context?.clientId ?? 0 + const isStale = context?.isStale ?? (() => false) + const existing = this.watches.get(rootPath) + if (existing) { + existing.clients.set(clientId, isStale) + await this.awaitSetupForClient(existing, clientId, context) + return + } + + if (this.watches.size >= MAX_RELAY_WATCH_ROOTS) { + throw new Error('Maximum number of file watchers reached') + } + + const state: RelayWatchState = { + rootPath, + clients: new Map([[clientId, isStale]]), + setupPromise: Promise.resolve(), + subscription: null, + abortController: new AbortController(), + generation: 0, + closed: false + } + this.watches.set(rootPath, state) + state.setupPromise = this.startInitialWatch(state) + await this.awaitSetupForClient(state, clientId, context) + } + + unwatch(rootPath: string, context?: RequestContext): void { + const state = this.watches.get(rootPath) + if (state) { + this.releaseWatchClient(state, context?.clientId ?? 0) + } + } + + dispose(): void { + for (const state of Array.from(this.watches.values())) { + this.closeWatch(state) + } + this.watcherPool.dispose() + } + + private async startInitialWatch(state: RelayWatchState): Promise { + try { + await this.subscribeState(state) + } catch (firstError) { + if (!state.closed && shouldRetryInitialWatch(firstError)) { + try { + await this.subscribeState(state) + this.emitOverflow(state) + return + } catch (quarantineError) { + this.closeWatch(state) + throw quarantineError + } + } + this.closeWatch(state) + throw firstError + } + } + + private subscribeState(state: RelayWatchState): Promise { + const generation = ++state.generation + const emitOverflow = (): void => { + if (state.generation === generation) { + this.emitOverflow(state) + } + } + return this.watcherPool + .subscribe( + state.rootPath, + (error, events) => { + if (state.closed || state.generation !== generation) { + return + } + if (error) { + process.stderr.write( + `[relay] File watcher error for ${state.rootPath}: ${error.message}\n` + ) + emitOverflow() + return + } + this.emitEvents(state, events) + }, + RELAY_WATCH_OPTIONS, + { + delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS }, + onInterruption: emitOverflow, + onOverflow: emitOverflow, + onTerminalError: (error) => this.recoverWatch(state, generation, error), + signal: state.abortController.signal, + subscribeTimeoutMs: RELAY_WATCH_CRAWL_TIMEOUT_MS + } + ) + .then(async (subscription) => { + if ( + state.closed || + state.generation !== generation || + this.watches.get(state.rootPath) !== state + ) { + await subscription.unsubscribe() + return + } + state.subscription = subscription + }) + } + + private recoverWatch(state: RelayWatchState, failedGeneration: number, error: Error): void { + if (state.closed || state.generation !== failedGeneration) { + return + } + state.subscription = null + this.emitOverflow(state) + const recovery = this.subscribeState(state) + state.setupPromise = recovery + void recovery.catch((recoveryError: unknown) => { + if (!state.closed) { + const message = recoveryError instanceof Error ? recoveryError.message : error.message + process.stderr.write( + `[relay] File watcher disabled after bounded recovery for ${state.rootPath}: ${message}\n` + ) + this.closeWatch(state) + } + }) + } + + private emitEvents(state: RelayWatchState, events: readonly WatcherProcessEvent[]): void { + if (state.closed || events.length === 0) { + return + } + this.dispatcher.notify('fs.changed', { + events: events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) + }) + } + + private emitOverflow(state: RelayWatchState): void { + if (!state.closed) { + this.dispatcher.notify('fs.changed', overflowEvent(state.rootPath)) + } + } + + private async awaitSetupForClient( + state: RelayWatchState, + clientId: number, + context?: RequestContext + ): Promise { + try { + await this.awaitSetupWithAbort(state.setupPromise, context?.signal) + } catch (error) { + this.releaseWatchClient(state, clientId) + const expectedAbort = + (error instanceof Error && error.name === 'AbortError') || + (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') + if (!expectedAbort && error instanceof Error) { + process.stderr.write( + `[relay] File watcher not available for ${state.rootPath}: ${error.message}\n` + ) + throw error + } + return + } + if (context?.isStale()) { + this.releaseWatchClient(state, clientId) + } + } + + private awaitSetupWithAbort(setupPromise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return setupPromise + } + if (signal.aborted) { + return Promise.reject(createWatchAbortError()) + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + cleanup() + reject(createWatchAbortError()) + } + const cleanup = (): void => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + setupPromise.then( + () => { + cleanup() + resolve() + }, + (error) => { + cleanup() + reject(error) + } + ) + }) + } + + private releaseClientWatches(clientId: number): void { + for (const state of Array.from(this.watches.values())) { + this.releaseWatchClient(state, clientId) + } + } + + private releaseStaleWatches(): void { + for (const state of Array.from(this.watches.values())) { + for (const [clientId, isStale] of state.clients) { + if (isStale()) { + state.clients.delete(clientId) + } + } + if (state.clients.size === 0) { + this.closeWatch(state) + } + } + } + + private releaseWatchClient(state: RelayWatchState, clientId: number): void { + state.clients.delete(clientId) + if (!state.closed && state.clients.size === 0) { + this.closeWatch(state) + } + } + + private closeWatch(state: RelayWatchState): void { + if (state.closed) { + return + } + state.closed = true + state.generation++ + state.abortController.abort() + state.clients.clear() + if (this.watches.get(state.rootPath) === state) { + this.watches.delete(state.rootPath) + } + const subscription = state.subscription + state.subscription = null + if (subscription) { + // Why: a child can die during unwatch; release quarantine history even + // when physical teardown reports that already-contained failure. + void subscription.unsubscribe().then( + () => this.watcherPool.forgetRoot(state.rootPath), + () => this.watcherPool.forgetRoot(state.rootPath) + ) + return + } + void state.setupPromise.then( + () => this.watcherPool.forgetRoot(state.rootPath), + () => this.watcherPool.forgetRoot(state.rootPath) + ) + } +} diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts new file mode 100644 index 00000000000..af6cb8285e2 --- /dev/null +++ b/src/relay/relay-watcher-process-pool.ts @@ -0,0 +1,26 @@ +import { join } from 'node:path' +import { RuntimeWatcherProcessPool } from '../main/ipc/runtime-watcher-process-pool' +import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor' + +export type RelayWatcherProcessPool = Pick< + RuntimeWatcherProcessPool, + 'dispose' | 'forgetRoot' | 'subscribe' +> + +export function getRelayWatcherProcessEntryPath(): string { + return join(__dirname, 'relay-watcher.js') +} + +export function createRelayWatcherProcessPool( + entryPath = getRelayWatcherProcessEntryPath() +): RelayWatcherProcessPool { + return new RuntimeWatcherProcessPool({ + createSupervisor: () => + new WatcherProcessSupervisor({ + entryPath, + // Why: a leaked VITEST environment must never move the native addon + // back into the relay when its crash-isolation child is missing. + useInProcessVitestFallback: false + }) + }) +} diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 4d19542b91c..219addc02a9 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -10,6 +10,7 @@ import { getEndpointFileName } from '../shared/agent-hook-listener' import { relayTestSocketPath } from './relay-test-socket-path' const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') +const WATCHER_TS_ENTRY = path.resolve(__dirname, '../main/ipc/parcel-watcher-process-entry.ts') let bundleDir: string let relayEntry: string const spawnedSocketDirs: string[] = [] @@ -24,7 +25,17 @@ beforeAll(async () => { target: 'node18', format: 'cjs', outfile: relayEntry, - external: ['node-pty', '@parcel/watcher'], + external: ['node-pty', '@parcel/watcher', 'electron'], + sourcemap: false + }) + await build({ + entryPoints: [WATCHER_TS_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: path.join(bundleDir, 'relay-watcher.js'), + external: ['@parcel/watcher'], sourcemap: false }) }, 30_000) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index c42be0d7bac..3b538d44335 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9736,13 +9736,21 @@ export default function TaskPage(): React.JSX.Element { ) : ( - + )} {item.type !== 'pr' ? ( diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index 6547c3704bd..d6fe7590f49 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -1,6 +1,8 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../../../shared/types' import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' +import { AGENT_FAVICON_ASSETS } from '@/lib/agent-favicon-assets' import AgentCombobox from './AgentCombobox' describe('AgentCombobox', () => { @@ -37,4 +39,14 @@ describe('AgentCombobox', () => { expect(markup).not.toContain('https://www.google.com/s2/favicons') expect(markup).not.toContain(' { + // Why: previously loaded from Google's favicon service (#8451). Iterate the + // full asset map so missing files/key mismatches fail the test. + for (const agent of Object.keys(AGENT_FAVICON_ASSETS) as TuiAgent[]) { + const markup = renderToStaticMarkup() + expect(markup).toContain(`/shared/agent-icons/${agent}.png`) + expect(markup).not.toContain('https://www.google.com/s2/favicons') + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts new file mode 100644 index 00000000000..f38f3bda8bc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts @@ -0,0 +1,128 @@ +import type { IBufferLine } from '@xterm/xterm' +import { describe, expect, it, vi } from 'vitest' +import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' +import { + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS, + TERMINAL_HTTP_URL_MAX_LENGTH +} from './terminal-http-link-limits' + +function bufferLine(text: string, onTranslateWithColumns: () => void): IBufferLine { + return { + isWrapped: false, + length: text.length, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + if (outColumns) { + onTranslateWithColumns() + } + outColumns?.push(...Array.from({ length: text.length + 1 }, (_value, index) => index)) + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function bufferLineWithCellColumns(text: string): IBufferLine { + const columns: number[] = [] + let column = 0 + for (const character of text) { + columns.push(column) + column += character === '你' ? 2 : 1 + } + columns.push(column) + return { + isWrapped: false, + length: column, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + outColumns?.push(...columns) + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +describe('hard-wrapped terminal HTTP candidate bounds', () => { + it('builds each required column map once and rejects an overlength candidate early', () => { + const onTranslateWithColumns = vi.fn() + const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) => + bufferLine(`| ${index === 0 ? 'http://x' : 'aaaaaaaa'} |`, onTranslateWithColumns) + ) + + expect( + buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + ) + ).toEqual([]) + expect(onTranslateWithColumns).toHaveBeenCalledTimes( + Math.floor(TERMINAL_HTTP_URL_MAX_LENGTH / 'aaaaaaaa'.length) + 1 + ) + }) + + it('does not build column maps while rejecting rows without an HTTP scheme', () => { + const onTranslateWithColumns = vi.fn() + const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, () => + bufferLine(`| ${'a'.repeat(500)} |`, onTranslateWithColumns) + ) + + expect( + buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + ) + ).toEqual([]) + expect(onTranslateWithColumns).not.toHaveBeenCalled() + }) + + it('reconstructs framed URL rows aligned by cells when a fragment has wide characters', () => { + const rowTexts = ['|http://a/ab|', '|a你你你你你|', '|tailzzzzzzz|'] + const rows = rowTexts.map(bufferLineWithCellColumns) + + const candidates = buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + rows.length + ) + + expect(candidates[0]?.text).toBe('http://a/aba你你你你你tailzzzzzzz') + }) + + it('reconstructs a maximum-length wide-character URL beyond the ASCII row bound', () => { + const scheme = 'http://' + const continuation = '你'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - scheme.length) + const rows = [ + bufferLineWithCellColumns(`|${scheme}|`), + ...continuation + .match(/.{1,3}/g)! + .map((fragment) => + bufferLineWithCellColumns(`|${fragment}${' '.repeat(7 - fragment.length * 2)}|`) + ) + ] + + expect(rows.length).toBeGreaterThan(Math.ceil(TERMINAL_HTTP_URL_MAX_LENGTH / scheme.length)) + expect( + buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text + ).toBe(scheme + continuation) + }) + + it('reconstructs a maximum-length URL with one-character continuation rows', () => { + const firstFragment = 'http://a' + const continuation = '/'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - firstFragment.length) + const rows = [ + bufferLineWithCellColumns(`|${firstFragment}|`), + ...continuation.split('').map((fragment) => bufferLineWithCellColumns(`|${fragment} |`)) + ] + const fullUrl = firstFragment + continuation + + expect(() => new URL(fullUrl)).not.toThrow() + expect( + buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text + ).toBe(fullUrl) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts new file mode 100644 index 00000000000..30b1d3a9f3c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts @@ -0,0 +1,148 @@ +import type { IBufferLine } from '@xterm/xterm' +import { + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS, + TERMINAL_HTTP_URL_MAX_LENGTH +} from './terminal-http-link-limits' +import { translateLineWithColumns, type WrappedLogicalLine } from './wrapped-terminal-link-ranges' + +const HTTP_SCHEME_PATTERN = /https?:\/\//i +const HTTP_SCHEME_START_PATTERN = /^https?:\/\//i +const HTTP_FRAGMENT_PATTERN = /^[^\s"'!*(){}|\\^<>`│┃║╎╏┆┇┊┋]*/ +const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/ +const NON_LAYOUT_SUFFIX_PATTERN = /[^\s│┃║╎╏┆┇┊┋|]/ +const HARD_WRAP_CONTINUATION_SUFFIX_PATTERN = /[/?&=#%+:-]$/ +const MIN_HARD_WRAPPED_HTTP_ROWS = 3 +const MIN_HARD_WRAP_FILL_RATIO = 0.8 + +type TranslatedLine = ReturnType + +function buildCandidateFromStart( + buffer: { getLine(y: number): IBufferLine | undefined }, + startY: number, + currentY: number, + translatedLines: Map +): WrappedLogicalLine | null { + const startLine = buffer.getLine(startY) + if (!startLine) { + return null + } + const cachedStart = translatedLines.get(startY) + const startText = cachedStart?.text ?? startLine.translateToString(false) + const schemeIndex = startText.search(HTTP_SCHEME_PATTERN) + if (schemeIndex === -1 || !VERTICAL_LAYOUT_FRAME_PATTERN.test(startText.slice(0, schemeIndex))) { + return null + } + const translatedStart = cachedStart ?? translateLineWithColumns(startLine) + translatedLines.set(startY, translatedStart) + const schemeColumn = translatedStart.columns[schemeIndex] + if (schemeColumn === undefined) { + return null + } + + const continuationPrefix = translatedStart.text.slice(0, schemeIndex) + let text = '' + let rightFrameColumn: number | null = null + let previousRowCanContinue = true + let startRowFilled = false + const rows: WrappedLogicalLine['rows'] = [] + + for (let rowY = startY; rowY < startY + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS; rowY++) { + if (rowY > startY && !previousRowCanContinue) { + break + } + const line = buffer.getLine(rowY) + if (!line) { + break + } + const translated = + rowY === startY + ? translatedStart + : (translatedLines.get(rowY) ?? translateLineWithColumns(line)) + translatedLines.set(rowY, translated) + if (rowY > startY && translated.text.slice(0, schemeIndex) !== continuationPrefix) { + break + } + + const fragment = translated.text.slice(schemeIndex).match(HTTP_FRAGMENT_PATTERN)?.[0] ?? '' + if (!fragment || (rowY > startY && HTTP_SCHEME_START_PATTERN.test(fragment))) { + break + } + const fragmentEnd = schemeIndex + fragment.length + const layoutSuffix = translated.text.slice(fragmentEnd) + const rightFrameOffset = layoutSuffix.search(VERTICAL_LAYOUT_FRAME_PATTERN) + const currentRightFrameIndex = rightFrameOffset === -1 ? -1 : fragmentEnd + rightFrameOffset + const currentRightFrameColumn = translated.columns[currentRightFrameIndex] + if ( + currentRightFrameIndex === -1 || + currentRightFrameColumn === undefined || + (rightFrameColumn !== null && currentRightFrameColumn !== rightFrameColumn) || + NON_LAYOUT_SUFFIX_PATTERN.test(layoutSuffix) + ) { + break + } + rightFrameColumn ??= currentRightFrameColumn + + if (text.length + fragment.length > TERMINAL_HTTP_URL_MAX_LENGTH) { + return null + } + + rows.push({ + y: rowY, + text: fragment, + sourceText: translated.text, + columns: translated.columns.slice(schemeIndex, fragmentEnd + 1), + startIndex: text.length, + isWrapped: line.isWrapped, + lineLength: line.length + }) + text += fragment + + const contentWidth = currentRightFrameColumn - schemeColumn + const fragmentWidth = translated.columns[fragmentEnd]! - schemeColumn + const fillsRow = contentWidth > 0 && fragmentWidth / contentWidth >= MIN_HARD_WRAP_FILL_RATIO + if (rowY === startY) { + startRowFilled = fillsRow + } + previousRowCanContinue = HARD_WRAP_CONTINUATION_SUFFIX_PATTERN.test(fragment) || fillsRow + } + + if (rows.length > 1 && (rows.length < MIN_HARD_WRAPPED_HTTP_ROWS || !startRowFilled)) { + // Why: a short complete URL can legitimately end in `/`; one adjacent + // framed token is not enough evidence that the URL continued onto it. + rows.splice(1) + text = rows[0]?.text ?? '' + } + if (rows.at(-1)?.y === undefined || rows.at(-1)!.y < currentY) { + return null + } + return { + text, + rows, + fingerprint: `hard-http:${rows.map((row) => `${row.y}:${row.sourceText}`).join('\0')}` + } +} + +export function buildHardWrappedHttpLogicalLineCandidates( + buffer: { getLine(y: number): IBufferLine | undefined }, + bufferLineNumber: number +): WrappedLogicalLine[] { + const currentY = bufferLineNumber - 1 + const currentLine = buffer.getLine(currentY) + if (!currentLine || !VERTICAL_LAYOUT_FRAME_PATTERN.test(currentLine.translateToString(false))) { + // Why: cursor-positioned hard wraps require a stable vertical frame; most + // terminal clicks can avoid the bounded backward scan entirely. + return [] + } + const candidates: WrappedLogicalLine[] = [] + const translatedLines = new Map() + const minY = Math.max(0, currentY - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + 1) + for (let startY = currentY; startY >= minY; startY--) { + const candidate = buildCandidateFromStart(buffer, startY, currentY, translatedLines) + if (candidate) { + candidates.push(candidate) + } + } + return candidates.sort( + (left, right) => right.rows.length - left.rows.length || right.text.length - left.text.length + ) +} diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts index efdac51caef..185cc6460e6 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts @@ -1,4 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import en from '@/i18n/locales/en.json' +import es from '@/i18n/locales/es.json' +import ja from '@/i18n/locales/ja.json' +import ko from '@/i18n/locales/ko.json' +import zh from '@/i18n/locales/zh.json' import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast' @@ -41,6 +46,7 @@ describe('showOsc52ClipboardBlockedToast', () => { showOsc52ClipboardBlockedToast() + expect(toastInfoMock.mock.calls[0]?.[1]?.description).toContain('Grok') const options = toastInfoMock.mock.calls[0]?.[1] expect(options).toMatchObject({ action: { @@ -67,4 +73,14 @@ describe('showOsc52ClipboardBlockedToast', () => { expect(toastInfoMock).toHaveBeenCalledTimes(1) }) + + it('mentions Grok in every supported locale', () => { + const locales = [en, es, ja, ko, zh] + + for (const locale of locales) { + expect( + locale.auto.components.terminal.pane.osc52.clipboard.blocked.toast['7cf51f74fd'] + ).toContain('Grok') + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts index 127f40f7a99..fb0b5d73a60 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts @@ -19,7 +19,7 @@ export function showOsc52ClipboardBlockedToast(): void { { description: translate( 'auto.components.terminal.pane.osc52.clipboard.blocked.toast.7cf51f74fd', - 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.' + 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.' ), duration: 12_000, action: { diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts new file mode 100644 index 00000000000..ca11b713450 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('isTerminalHttpLinkActivation', () => { + it('leaves Alt+Cmd gestures to the child TUI on macOS', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect( + isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: false } as MouseEvent) + ).toBe(true) + expect( + isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: true } as MouseEvent) + ).toBe(false) + }) + + it('leaves Alt+Ctrl gestures to the child TUI on other platforms', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + + expect( + isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: false } as MouseEvent) + ).toBe(true) + expect( + isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: true } as MouseEvent) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts new file mode 100644 index 00000000000..c9e941c874d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts @@ -0,0 +1,7 @@ +import { isTerminalLinkActivation } from './terminal-link-activation' + +export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean { + // Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY, + // so plain HTTP link handling must leave those gestures to the child TUI. + return Boolean(event && !event.altKey && isTerminalLinkActivation(event)) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts new file mode 100644 index 00000000000..52cc2cf1e93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts @@ -0,0 +1,5 @@ +export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048 + +// Why: punctuation can form a valid one-character continuation row, so the +// URL length limit is also the only complete finite bound on framed rows. +export const TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS = TERMINAL_HTTP_URL_MAX_LENGTH diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts new file mode 100644 index 00000000000..e595974bc18 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -0,0 +1,59 @@ +import type { IDisposable, Terminal } from '@xterm/xterm' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' + +const CAPTURE_LISTENER_OPTIONS = { capture: true } as const + +export function installTerminalLinkPtyMouseSuppression( + terminal: Terminal, + shouldSuppressMouseEvent: (event: MouseEvent) => boolean +): IDisposable { + const terminalElement = terminal.element + const ownerDocument = terminalElement?.ownerDocument + const ownerWindow = ownerDocument?.defaultView + let previousMouseEventsRequireAlt: boolean | null = null + let restoreQueued = false + + const restore = (): void => { + restoreQueued = false + if (previousMouseEventsRequireAlt === null) { + return + } + terminal.options.mouseEventsRequireAlt = previousMouseEventsRequireAlt + previousMouseEventsRequireAlt = null + ownerDocument?.removeEventListener('mouseup', queueRestore) + ownerWindow?.removeEventListener('blur', restore) + } + const queueRestore = (): void => { + if (restoreQueued || previousMouseEventsRequireAlt === null) { + return + } + restoreQueued = true + queueMicrotask(restore) + } + const handleMouseDown = (event: MouseEvent): void => { + if ( + event.button !== 0 || + !isTerminalHttpLinkActivation(event) || + !shouldSuppressMouseEvent(event) + ) { + return + } + restore() + previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt) + // Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware + // TUI, letting the terminal and the child process both open the URL. + terminal.options.mouseEventsRequireAlt = true + ownerDocument?.addEventListener('mouseup', queueRestore) + ownerWindow?.addEventListener('blur', restore) + } + + terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + terminalElement?.addEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) + return { + dispose: () => { + restore() + terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + terminalElement?.removeEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts b/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts new file mode 100644 index 00000000000..ee741dbbc4f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +export function getTerminalBufferPositionForMouseEvent( + terminal: Terminal, + event: MouseEvent +): { x: number; y: number } | null { + const screenElement = terminal.element?.querySelector('.xterm-screen') + if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) { + return null + } + + const rect = screenElement.getBoundingClientRect() + const relativeX = event.clientX - rect.left + const relativeY = event.clientY - rect.top + if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) { + return null + } + + const cellWidth = rect.width / terminal.cols + const cellHeight = rect.height / terminal.rows + if (cellWidth <= 0 || cellHeight <= 0) { + return null + } + + return { + x: Math.floor(relativeX / cellWidth) + 1, + y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1 + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts new file mode 100644 index 00000000000..c8e85950da3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -0,0 +1,429 @@ +import type { IBufferLine, Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { handleTerminalWebLinkClick } from './terminal-web-link-click' +import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing' + +const COLS = 157 +const ROWS = 59 +const INDENT = '' +const FULL_URL = [ + 'http://127.0.0.1:8765/orca-double-open-repro-wrapped/', + Array.from({ length: 79 }, (_value, index) => `seg${String(index + 1).padStart(4, '0')}`).join( + '/' + ), + '?marker=wrap-test&n=001&pad=', + 'x'.repeat(120) +].join('') +const URL_ROWS = Array.from({ length: Math.ceil(FULL_URL.length / COLS) }, (_value, index) => + FULL_URL.slice(index * COLS, (index + 1) * COLS) +) +const FRAMED_ROW_STARTS = [ + 0, + FULL_URL.indexOf('seg0008/'), + FULL_URL.indexOf('seg0022/'), + FULL_URL.indexOf('seg0036/'), + FULL_URL.indexOf('seg0050/'), + FULL_URL.indexOf('seg0064/'), + FULL_URL.indexOf('seg0078/'), + FULL_URL.indexOf('test&n=001'), + FULL_URL.length - 19 +] +const FRAMED_URL_ROWS = FRAMED_ROW_STARTS.map((start, index) => + FULL_URL.slice(start, FRAMED_ROW_STARTS[index + 1]) +) + +const openUrlMock = vi.fn() + +type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined] + +function makeBufferLine( + fragment: string, + options: { cols?: number; prefix?: string; suffix?: string; isWrapped?: boolean } = {} +): IBufferLine { + const cols = options.cols ?? COLS + const prefix = options.prefix ?? INDENT + const suffix = options.suffix ?? '' + const text = `${prefix}${fragment}`.padEnd(cols - suffix.length) + suffix + return { + isWrapped: options.isWrapped ?? false, + length: cols, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + if (outColumns) { + outColumns.splice( + 0, + outColumns.length, + ...Array.from( + { length: endColumn - startColumn + 1 }, + (_value, index) => index + startColumn + ) + ) + } + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function makeTerminal(options?: { + cols?: number + rows?: number + urlRows?: string[] + linePrefix?: string + lineSuffix?: string + softWrapped?: boolean +}): { + terminal: Terminal + registrations: ListenerRegistration[] + clearSelection: ReturnType +} { + const cols = options?.cols ?? COLS + const rows = options?.rows ?? ROWS + const urlRows = options?.urlRows ?? URL_ROWS + const registrations: ListenerRegistration[] = [] + const ownerWindow = { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + const ownerDocument = { + defaultView: ownerWindow, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + const screen = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: cols * 10, height: rows * 10 }) + } + const element = { + ownerDocument, + querySelector: vi.fn(() => screen), + addEventListener: vi.fn( + (name: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => { + registrations.push([name, listener, options]) + } + ), + removeEventListener: vi.fn() + } + const clearSelection = vi.fn() + return { + terminal: { + cols, + rows, + options: { mouseEventsRequireAlt: false }, + element, + buffer: { + active: { + viewportY: 0, + getLine: (y: number) => + urlRows[y] && + makeBufferLine(urlRows[y], { + cols, + prefix: options?.linePrefix, + suffix: options?.lineSuffix, + isWrapped: (options?.softWrapped ?? true) && y > 0 + }) + } + }, + clearSelection + } as unknown as Terminal, + registrations, + clearSelection + } +} + +function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent { + let defaultPrevented = false + return { + button: 0, + metaKey: true, + ctrlKey: false, + altKey: options.altKey ?? false, + shiftKey: true, + get defaultPrevented() { + return defaultPrevented + }, + clientX: 150, + clientY: row * 10 + 5, + preventDefault: vi.fn(() => { + defaultPrevented = true + }) + } as unknown as MouseEvent +} + +describe('hard-wrapped terminal HTTP clicks', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: false }, + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn() + })) + openUrlMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('opens the full logical URL once when WebLinksAddon reports only the first row', () => { + const { terminal, registrations, clearSelection } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const event = mouseEventForRow(0) + + expect( + handleTerminalWebLinkClick(URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + expect(fallback).toBeDefined() + fallback!(event) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + expect(new URL(URL_ROWS[0]).pathname).toHaveLength(136) + expect(`${new URL(FULL_URL).pathname}${new URL(FULL_URL).search}`).toHaveLength(811) + expect(event.preventDefault).toHaveBeenCalled() + expect(clearSelection).toHaveBeenCalled() + disposable.dispose() + }) + + it('opens the same full URL from a continuation-row fallback click', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const event = mouseEventForRow(3) + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + + fallback!(event) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + disposable.dispose() + }) + + it('reconstructs a URL split across cursor-positioned rows inside a TUI frame', () => { + const { terminal } = makeTerminal({ + cols: 135, + urlRows: FRAMED_URL_ROWS, + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + const event = mouseEventForRow(0) + + expect( + handleTerminalWebLinkClick(FRAMED_URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + expect(new URL(FRAMED_URL_ROWS[0]).pathname).toHaveLength(88) + }) + + it('reconstructs a URL that fills each cursor-positioned TUI row up to its frame', () => { + const cols = 135 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const fullWidthRows = Array.from( + { length: Math.ceil(FULL_URL.length / contentWidth) }, + (_value, index) => FULL_URL.slice(index * contentWidth, (index + 1) * contentWidth) + ) + const { terminal } = makeTerminal({ + cols, + urlRows: fullWidthRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick(fullWidthRows[0], mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + }) + + it('reconstructs supported URLs spanning more than twenty framed rows', () => { + const cols = 80 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const longUrl = `http://example.com/${'a'.repeat(contentWidth * 20)}` + const urlRows = Array.from( + { length: Math.ceil(longUrl.length / contentWidth) }, + (_value, index) => longUrl.slice(index * contentWidth, (index + 1) * contentWidth) + ) + const { terminal } = makeTerminal({ + cols, + urlRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect(urlRows).toHaveLength(21) + expect( + handleTerminalWebLinkClick(urlRows[0], mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledWith(longUrl) + }) + + it('keeps nested HTTP URLs inside a wrapped query parameter', () => { + const cols = 80 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const firstRow = `http://example.com/${'a'.repeat(contentWidth - 'http://example.com/'.length)}` + const nestedQuery = 'segment?redirect=https://nested.example/path' + const secondRow = `${nestedQuery}${'b'.repeat(contentWidth - nestedQuery.length)}` + const urlRows = [firstRow, secondRow, 'tail'] + const fullUrl = urlRows.join('') + const { terminal } = makeTerminal({ + cols, + urlRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick(firstRow, mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledWith(fullUrl) + }) + + it('does not append an unrelated aligned TUI row to a complete URL', () => { + const { terminal, registrations } = makeTerminal({ + cols: 135, + urlRows: ['http://example.com/', 'next-token'], + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + + expect( + handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('http://example.com/') + + openUrlMock.mockReset() + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + fallback!(mouseEventForRow(1)) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('does not join a complete URL to multiple unrelated framed rows', () => { + const unrelatedFilledRow = 'a'.repeat(103) + const { terminal } = makeTerminal({ + cols: 110, + urlRows: ['http://example.com/', unrelatedFilledRow, 'unrelated'], + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('http://example.com/') + }) + + it('does not suppress a modifier-click when the buffer position is not an HTTP link', () => { + const { terminal, registrations } = makeTerminal({ urlRows: ['not-a-link'] }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + + mouseDown!(mouseEventForRow(0)) + + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + disposable.dispose() + }) + + it('leaves Alt-modified link gestures to the child TUI', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const event = mouseEventForRow(0, { altKey: true }) + + mouseDown!(event) + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + expect( + handleTerminalWebLinkClick(URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('temporarily suppresses PTY mouse reporting at an HTTP link position', async () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const mouseUp = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options !== undefined + )?.[1] + + mouseDown!(mouseEventForRow(0)) + expect(terminal.options.mouseEventsRequireAlt).toBe(true) + + mouseUp!(mouseEventForRow(0)) + await Promise.resolve() + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + disposable.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 67505d5f622..bfbbe29f723 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,8 +1,15 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' -import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' -import { isTerminalLinkActivation } from './terminal-link-activation' -import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' +import { + buildCandidateLogicalLinesForBufferPosition, + dedupeLogicalLines +} from './terminal-file-link-hit-testing' +import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression' +import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position' +import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' +import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' type UrlLinkHitTestDeps = { worktreeId: string @@ -26,7 +33,7 @@ type ParsedTerminalHttpLink = { } const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const -export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048 +export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] { const links: ParsedTerminalHttpLink[] = [] @@ -56,7 +63,7 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so // plain clicks remain available for cursor placement and selection. Mobile // tap routing is handled separately under mobile/src/terminal. - return isTerminalLinkActivation(event) + return isTerminalHttpLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -185,56 +192,40 @@ function isAsciiWordCode(code: number): boolean { ) } -function getTerminalScreenElement(terminal: Terminal): HTMLElement | null { - return terminal.element?.querySelector('.xterm-screen') ?? null -} - -function getBufferPositionForTerminalMouseEvent( +export function openHttpLinkAtTerminalMouseEvent( terminal: Terminal, - event: MouseEvent -): { x: number; y: number } | null { - const screenElement = getTerminalScreenElement(terminal) - if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) { - return null + event: MouseEvent, + deps: UrlLinkHitTestDeps +): boolean { + if (event.button !== 0 || !isTerminalHttpLinkActivation(event)) { + return false } - - const rect = screenElement.getBoundingClientRect() - const relativeX = event.clientX - rect.left - const relativeY = event.clientY - rect.top - if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) { - return null - } - - const cellWidth = rect.width / terminal.cols - const cellHeight = rect.height / terminal.rows - if (cellWidth <= 0 || cellHeight <= 0) { - return null - } - - return { - x: Math.floor(relativeX / cellWidth) + 1, - y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1 + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + if (!position) { + return false } + return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps) } export function installHttpLinkClickFallback( terminal: Terminal, deps: UrlLinkClickFallbackDeps ): IDisposable { + const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression(terminal, (event) => { + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + return Boolean( + position && findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols) + ) + }) const handleMouseUp = (event: MouseEvent): void => { if (!isDesktopHttpLinkFallbackActivation(event)) { return } - const position = getBufferPositionForTerminalMouseEvent(terminal, event) - if (!position) { - return - } - // Why: xterm's WebLinksAddon only activates after hover state exists. This // direct mouseup fallback preserves modifier-clicks when the hover link was // never established, while defaultPrevented avoids duplicate opens. - const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { + const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference @@ -249,6 +240,7 @@ export function installHttpLinkClickFallback( terminalElement?.addEventListener('mouseup', handleMouseUp) return { dispose: () => { + ptyMouseSuppression.dispose() terminalElement?.removeEventListener('mouseup', handleMouseUp) } } @@ -260,10 +252,30 @@ export function openHttpLinkAtBufferPosition( terminalColumns: number, deps: UrlLinkHitTestDeps ): boolean { - const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y) - if (logicalLines.length === 0) { + const url = findHttpLinkAtBufferPosition(buffer, position, terminalColumns) + if (!url) { return false } + openTerminalHttpLink(url, deps) + return true +} + +function findHttpLinkAtBufferPosition( + buffer: { getLine(y: number): IBufferLine | undefined }, + position: { x: number; y: number }, + terminalColumns: number +): string | null { + const nativeWrappedLogicalLine = buildWrappedLogicalLine(buffer, position.y) + const logicalLines = dedupeLogicalLines([ + ...(nativeWrappedLogicalLine && nativeWrappedLogicalLine.rows.length > 1 + ? [nativeWrappedLogicalLine] + : []), + ...buildHardWrappedHttpLogicalLineCandidates(buffer, position.y), + ...buildCandidateLogicalLinesForBufferPosition(buffer, position.y) + ]) + if (logicalLines.length === 0) { + return null + } for (const logicalLine of logicalLines) { for (const parsed of extractTerminalHttpLinks(logicalLine.text)) { @@ -271,12 +283,11 @@ export function openHttpLinkAtBufferPosition( if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) { continue } - openTerminalHttpLink(parsed.url, deps) - return true + return parsed.url } } - return false + return null } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts new file mode 100644 index 00000000000..c512cc84126 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -0,0 +1,50 @@ +import type { Terminal } from '@xterm/xterm' +import type { LinkHandlerDeps } from './terminal-link-handlers' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { handleOscLink } from './terminal-osc-link-routing' +import { + openHttpLinkAtTerminalMouseEvent, + type TerminalLinkRoutingPreferenceRequester +} from './terminal-url-link-hit-testing' + +type TerminalWebLinkClickDeps = Pick< + LinkHandlerDeps, + 'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath' +> & { + terminal: Terminal | null + requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester +} + +export function handleTerminalWebLinkClick( + url: string, + event: MouseEvent | undefined, + deps: TerminalWebLinkClickDeps +): boolean { + if (!event || !isTerminalHttpLinkActivation(event)) { + return false + } + + let handled: boolean + if ( + deps.terminal && + openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { + worktreeId: deps.worktreeId, + forceSystemBrowser: Boolean(event.shiftKey), + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference + }) + ) { + // Why: WebLinksAddon only knows the physical row; Orca's logical hit-test + // preserves the complete URL rendered across hard-wrapped TUI rows. + event.preventDefault() + handled = true + } else { + handled = handleOscLink(url, event, deps) + } + + if (handled) { + // Why: link navigation can steal focus before xterm's mouseup cleanup; + // clearing selection also detaches its pending drag-selection listeners. + deps.terminal?.clearSelection() + } + return handled +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 5f68494ba15..5a64694000e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -31,6 +31,7 @@ import { import { createTerminalHandleLinkProvider } from './terminal-handle-links' import type { LinkHandlerDeps } from './terminal-link-handlers' import { handleOscLink } from './terminal-osc-link-routing' +import { handleTerminalWebLinkClick } from './terminal-web-link-click' import { installHttpLinkClickFallback, type TerminalLinkRoutingPreferenceRequester @@ -1480,28 +1481,16 @@ export function useTerminalPaneLifecycle({ terminalTuiScrollSensitivity: () => normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity), onLinkClick: (event, url) => { - if (!event) { - return - } const activePane = managerRef.current?.getActivePane() - const handled = handleOscLink(url, event, { + handleTerminalWebLinkClick(url, event, { ...linkDeps, + terminal: activePane?.terminal ?? null, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null) : null, requestOpenLinksInAppPreference }) - // Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus - // away from the terminal before the click's mouseup reaches - // ownerDocument. That leaves xterm's SelectionService drag-select - // mousemove listener attached, so subsequent mouse motion extends a - // phantom selection until the next click/Esc. Explicitly clearing the - // selection also detaches those listeners (see - // SelectionService._removeMouseDownListeners). - if (handled) { - managerRef.current?.getActivePane()?.terminal.clearSelection() - } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees diff --git a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts index b648950bcd0..b94affd030b 100644 --- a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts +++ b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts @@ -65,7 +65,7 @@ function translateLineWithCells(line: IBufferLine): { text: string; columns: num return { text, columns } } -function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } { +export function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } { const columns: number[] = [] const text = (line as TerminalBufferLineWithColumns).translateToString( false, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2434660b84a..40492be7b71 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2588,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "Open Setting", - "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.", + "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.", "89eaa3e80b": "Terminal clipboard write blocked" } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a216c3dfe1c..f909b7687b9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2588,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "Abrir ajustes", - "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim o fzf.", + "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim, fzf o Grok.", "89eaa3e80b": "Escritura al portapapeles del terminal bloqueada" } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 641d28e1489..33bc07fbfd1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2588,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "設定を開く", - "7cf51f74fd": "SSH、tmux、Neovim、または fzf からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", + "7cf51f74fd": "SSH、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", "89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました" } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c159ed4759b..67187a4b21c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2588,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "설정 열기", - "7cf51f74fd": "SSH, tmux, Neovim 또는 fzf에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", + "7cf51f74fd": "SSH, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", "89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다." } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3b1c79a3428..dbf29920404 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2588,7 +2588,7 @@ "blocked": { "toast": { "97c98f1afe": "打开设置", - "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。", + "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim、fzf 或 Grok 进行复制。", "89eaa3e80b": "终端剪贴板写入被阻止" } } diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index d43401e502f..f9536dc1c34 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -14,6 +14,7 @@ import { } from './agent-icon-glyphs' import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { AGENT_FAVICON_ASSETS } from './agent-favicon-assets' export type AgentCatalogEntry = { id: TuiAgent @@ -113,7 +114,8 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => id: 'omp', label: translate('auto.lib.agent.catalog.09973b4d84', 'OMP'), cmd: 'omp', - faviconDomain: 'omp.sh', + // Why: no faviconDomain — omp renders the hand-authored OmpIcon glyph, so a + // favicon fallback would never be reached. homepageUrl: 'https://omp.sh' }, { @@ -340,20 +342,29 @@ export function AgentIcon({ return } const catalogEntry = getAgentCatalog().find((a) => a.id === agent) - if (catalogEntry?.iconUrl) { + // Why: prefer the favicon bundled at build time so the icon renders without a + // live network request — Google's favicon service is unreachable in some + // regions and offline, which left these icons broken (#8451). + const bundledFaviconUrl = AGENT_FAVICON_ASSETS[agent] + // Why: one resolved src for guard + attribute so empty `iconUrl` cannot pass + // a truthy `||` check while `??` still renders a broken ``. + const iconSrc = catalogEntry?.iconUrl ?? bundledFaviconUrl + if (iconSrc) { return ( ) } if (catalogEntry?.faviconDomain) { - // Why: agents without a published SVG icon use their site favicon via - // Google's favicon service — same source the README uses for the agent badge list. + // Why: agents without a published SVG icon or bundled favicon fall back to + // their site favicon via Google's favicon service — same source the README + // uses for the agent badge list. return ( > = { + grok: grokUrl, + 'mimo-code': mimoCodeUrl, + ante: anteUrl, + gemini: geminiUrl, + antigravity: antigravityUrl, + goose: gooseUrl, + amp: ampUrl, + kiro: kiroUrl, + crush: crushUrl, + aug: augUrl, + autohand: autohandUrl, + cline: clineUrl, + codebuff: codebuffUrl, + 'command-code': commandCodeUrl, + continue: continueUrl, + cursor: cursorUrl, + kimi: kimiUrl, + 'mistral-vibe': mistralVibeUrl, + 'qwen-code': qwenCodeUrl, + rovo: rovoUrl, + hermes: hermesUrl, + devin: devinUrl, + openclaw: openclawUrl +} diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 083783328d3..a593d3bcae9 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -1639,6 +1639,44 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.interactivePrompt).toBeUndefined() }) + it('surfaces the Grok tool-failure error and clears stale tool fields', () => { + normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'pre_tool_use', + toolName: 'run_terminal_command', + toolInput: { command: 'pnpm build' } + } + }, + 'production' + ) + const failed = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'post_tool_use_failure', + toolName: 'run_terminal_command', + toolInput: { command: 'pnpm build' }, + error: 'command exited with code 1' + } + }, + 'production' + ) + // Why: keeping toolName set would let the compact sidebar show the tool + // instead of the failure text, hiding the error from the user. + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'command exited with code 1' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + it('maps Grok StopFailure to done', () => { normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 59724b64322..ce3e2f720a4 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -692,6 +692,17 @@ function toolUpdate( } } +/** Clear the active-tool metadata (name/input/prompt) so a just-failed tool + * stops looking in-flight. Why: the compact sidebar prioritizes current-tool + * metadata over the failure message, so a completed failed tool must no + * longer look active or the error text stays hidden behind the tool name. */ +function clearActiveToolFieldsUpdate(): ToolSnapshot { + return toolUpdate( + { toolName: undefined, toolInput: undefined, interactivePrompt: undefined }, + { hasToolInputField: true } + ) +} + /** True for the AskUserQuestion tool across the casing variants different * agents emit (`AskUserQuestion` / `ask_user_question` / `askUserQuestion`). * Why: this is the structured "pick an option" prompt whose full input the @@ -1375,10 +1386,11 @@ function extractClaudeToolFields( hookPayload: Record ): ToolSnapshot { const update: ToolSnapshot = {} - if ( + if (eventName === 'PostToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( eventName === 'PreToolUse' || eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || eventName === 'PermissionRequest' ) { const toolName = readString(hookPayload, 'tool_name') @@ -1600,12 +1612,20 @@ function extractCursorToolFields( eventName === 'postToolUse' || eventName === 'postToolUseFailure' ) { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) + const update: ToolSnapshot = {} + if (eventName === 'postToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + Object.assign( + update, + toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + ) + } if (eventName === 'postToolUse') { const responseText = extractToolResponseText(hookPayload.tool_output) if (responseText) { @@ -1750,10 +1770,11 @@ function extractCopilotToolFields( hookPayload: Record ): ToolSnapshot { const update: ToolSnapshot = {} - if ( + if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( eventName === 'PreToolUse' || eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || eventName === 'PermissionRequest' ) { const copilotToolCall = readCopilotToolCall(hookPayload) @@ -2016,29 +2037,40 @@ function extractGrokToolFields( grokHome?: string ): ToolSnapshot { if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { - const toolName = - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') - const rawInput = - hookPayload.toolInput ?? hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments - const toolInput = - deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) - // Why: Grok's ask_user_question is auto-allowed and arrives as PreToolUse - // (not PermissionRequest). Capture the full question payload so the live - // card path can render options instead of only a waiting Notification. - const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput, interactivePrompt }, - { - hasToolInputField: hasAnyOwnField(hookPayload, [ - 'toolInput', - 'tool_input', - 'input', - 'arguments' - ]) - } - ) + const update: ToolSnapshot = {} + if (isGrokEvent(eventName, 'post_tool_use_failure')) { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + const rawInput = + hookPayload.toolInput ?? + hookPayload.tool_input ?? + hookPayload.input ?? + hookPayload.arguments + const toolInput = + deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) + // Why: Grok's ask_user_question is auto-allowed and arrives as PreToolUse + // (not PermissionRequest). Capture the full question payload so the live + // card path can render options instead of only a waiting Notification. + const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) + Object.assign( + update, + toolUpdate( + { toolName, toolInput, interactivePrompt }, + { + hasToolInputField: hasAnyOwnField(hookPayload, [ + 'toolInput', + 'tool_input', + 'input', + 'arguments' + ]) + } + ) + ) + } if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) { const responseText = extractToolResponseText(hookPayload.toolResponse) ?? diff --git a/src/shared/agent-icons/amp.png b/src/shared/agent-icons/amp.png new file mode 100644 index 00000000000..9b57e20ff0f Binary files /dev/null and b/src/shared/agent-icons/amp.png differ diff --git a/src/shared/agent-icons/ante.png b/src/shared/agent-icons/ante.png new file mode 100644 index 00000000000..c3dadcc2cec Binary files /dev/null and b/src/shared/agent-icons/ante.png differ diff --git a/src/shared/agent-icons/antigravity.png b/src/shared/agent-icons/antigravity.png new file mode 100644 index 00000000000..be2992716b5 Binary files /dev/null and b/src/shared/agent-icons/antigravity.png differ diff --git a/src/shared/agent-icons/aug.png b/src/shared/agent-icons/aug.png new file mode 100644 index 00000000000..1664844ad7f Binary files /dev/null and b/src/shared/agent-icons/aug.png differ diff --git a/src/shared/agent-icons/autohand.png b/src/shared/agent-icons/autohand.png new file mode 100644 index 00000000000..0c3326a5705 Binary files /dev/null and b/src/shared/agent-icons/autohand.png differ diff --git a/src/shared/agent-icons/cline.png b/src/shared/agent-icons/cline.png new file mode 100644 index 00000000000..f4bd762dfee Binary files /dev/null and b/src/shared/agent-icons/cline.png differ diff --git a/src/shared/agent-icons/codebuff.png b/src/shared/agent-icons/codebuff.png new file mode 100644 index 00000000000..b81a6fa9c21 Binary files /dev/null and b/src/shared/agent-icons/codebuff.png differ diff --git a/src/shared/agent-icons/command-code.png b/src/shared/agent-icons/command-code.png new file mode 100644 index 00000000000..2066461b996 Binary files /dev/null and b/src/shared/agent-icons/command-code.png differ diff --git a/src/shared/agent-icons/continue.png b/src/shared/agent-icons/continue.png new file mode 100644 index 00000000000..cd2599d6e63 Binary files /dev/null and b/src/shared/agent-icons/continue.png differ diff --git a/src/shared/agent-icons/copilot.png b/src/shared/agent-icons/copilot.png new file mode 100644 index 00000000000..52126e047d0 Binary files /dev/null and b/src/shared/agent-icons/copilot.png differ diff --git a/src/shared/agent-icons/crush.png b/src/shared/agent-icons/crush.png new file mode 100644 index 00000000000..6d880c1305d Binary files /dev/null and b/src/shared/agent-icons/crush.png differ diff --git a/src/shared/agent-icons/cursor.png b/src/shared/agent-icons/cursor.png new file mode 100644 index 00000000000..426cbfa4d0d Binary files /dev/null and b/src/shared/agent-icons/cursor.png differ diff --git a/src/shared/agent-icons/devin.png b/src/shared/agent-icons/devin.png new file mode 100644 index 00000000000..3eee8d0d8d0 Binary files /dev/null and b/src/shared/agent-icons/devin.png differ diff --git a/src/shared/agent-icons/droid.png b/src/shared/agent-icons/droid.png new file mode 100644 index 00000000000..014b63332be Binary files /dev/null and b/src/shared/agent-icons/droid.png differ diff --git a/src/shared/agent-icons/gemini.png b/src/shared/agent-icons/gemini.png new file mode 100644 index 00000000000..84976ebc7fd Binary files /dev/null and b/src/shared/agent-icons/gemini.png differ diff --git a/src/shared/agent-icons/goose.png b/src/shared/agent-icons/goose.png new file mode 100644 index 00000000000..2da26f064ce Binary files /dev/null and b/src/shared/agent-icons/goose.png differ diff --git a/src/shared/agent-icons/grok.png b/src/shared/agent-icons/grok.png new file mode 100644 index 00000000000..954e5b40da2 Binary files /dev/null and b/src/shared/agent-icons/grok.png differ diff --git a/src/shared/agent-icons/hermes.png b/src/shared/agent-icons/hermes.png new file mode 100644 index 00000000000..7d5ba85e54e Binary files /dev/null and b/src/shared/agent-icons/hermes.png differ diff --git a/src/shared/agent-icons/kilo.png b/src/shared/agent-icons/kilo.png new file mode 100644 index 00000000000..053dba3fd76 Binary files /dev/null and b/src/shared/agent-icons/kilo.png differ diff --git a/src/shared/agent-icons/kimi.png b/src/shared/agent-icons/kimi.png new file mode 100644 index 00000000000..3640a259f8d Binary files /dev/null and b/src/shared/agent-icons/kimi.png differ diff --git a/src/shared/agent-icons/kiro.png b/src/shared/agent-icons/kiro.png new file mode 100644 index 00000000000..4a6cc6bbf8c Binary files /dev/null and b/src/shared/agent-icons/kiro.png differ diff --git a/src/shared/agent-icons/mimo-code.png b/src/shared/agent-icons/mimo-code.png new file mode 100644 index 00000000000..45cb84ff516 Binary files /dev/null and b/src/shared/agent-icons/mimo-code.png differ diff --git a/src/shared/agent-icons/mistral-vibe.png b/src/shared/agent-icons/mistral-vibe.png new file mode 100644 index 00000000000..f0f799cf546 Binary files /dev/null and b/src/shared/agent-icons/mistral-vibe.png differ diff --git a/src/shared/agent-icons/openclaude.png b/src/shared/agent-icons/openclaude.png new file mode 100644 index 00000000000..1e660b74090 Binary files /dev/null and b/src/shared/agent-icons/openclaude.png differ diff --git a/src/shared/agent-icons/openclaw.png b/src/shared/agent-icons/openclaw.png new file mode 100644 index 00000000000..03feb5b7638 Binary files /dev/null and b/src/shared/agent-icons/openclaw.png differ diff --git a/src/shared/agent-icons/opencode.png b/src/shared/agent-icons/opencode.png new file mode 100644 index 00000000000..1f45b1faee0 Binary files /dev/null and b/src/shared/agent-icons/opencode.png differ diff --git a/src/shared/agent-icons/qwen-code.png b/src/shared/agent-icons/qwen-code.png new file mode 100644 index 00000000000..9a6346691d4 Binary files /dev/null and b/src/shared/agent-icons/qwen-code.png differ diff --git a/src/shared/agent-icons/rovo.png b/src/shared/agent-icons/rovo.png new file mode 100644 index 00000000000..08a2af624f8 Binary files /dev/null and b/src/shared/agent-icons/rovo.png differ diff --git a/tests/e2e/agent-session-live-force-exit-resume.spec.ts b/tests/e2e/agent-session-live-force-exit-resume.spec.ts index a66f60604f5..adc5ee2d579 100644 --- a/tests/e2e/agent-session-live-force-exit-resume.spec.ts +++ b/tests/e2e/agent-session-live-force-exit-resume.spec.ts @@ -16,6 +16,7 @@ import { import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const PROVIDER_SESSION_ID = 'e2e-live-force-exit-session' @@ -41,7 +42,8 @@ type PersistedData = { } function dataFilePath(userDataDir: string): string { - return path.join(userDataDir, 'orca-data.json') + // Fresh sessions migrate the seeded legacy file, then persist only here. + return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') } function readPersistedData(userDataDir: string): PersistedData { @@ -205,9 +207,8 @@ test('resumes a live agent record after force-exit restart when pane PTY ownersh } ) - // Drive the product's own quit-capture path (what the 60s timer / beforeunload - // run). Its origin:'quit' record differs from the live one, forcing a store - // change that triggers the (now hydration-gated) writer before the poll below. + // Exercise quit capture: origin:'quit' changes the live record, triggering the + // hydration-gated writer before polling persisted state. await page.evaluate(() => window.__store?.getState().captureAllSleepingAgentSessions()) await expect diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 52680b1bd76..f120d53ffe4 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -534,10 +534,8 @@ test.describe('Terminal Shortcuts', () => { await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(1) await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u') - // The shell is only standing in for a KKP-aware TUI and does not consume the - // CSI-u input above. Clear it deterministically (Ctrl-U kill-line + Ctrl-C) - // first, then send the idempotent "set flags to 0" reset as its own settled - // command so the reset byte can't be swallowed mid line-edit. + // Clear the shell's unconsumed CSI-u line before resetting flags in a settled + // command; otherwise its line editor can swallow the reset bytes. await sendToTerminal(orcaPage, ptyId, '\x15\x03') await execInTerminal(orcaPage, ptyId, "printf '\\033[=0u'") await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(0)