diff --git a/config/scripts/node-server-runtime-verifier.ts b/config/scripts/node-server-runtime-verifier.ts index 6f4aaaf052f..6d31d2e2b3d 100644 --- a/config/scripts/node-server-runtime-verifier.ts +++ b/config/scripts/node-server-runtime-verifier.ts @@ -1,10 +1,11 @@ import { execFileSync, spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { decodePairingOffer, type PairingOffer } from '../../src/shared/pairing' import { sendRemoteRuntimeRequest } from '../../src/shared/remote-runtime-client' import type { RuntimeStatus, RuntimeTerminalRead } from '../../src/shared/runtime-types' +import { stopNodeServerVerifierDaemons } from './node-server-verifier-daemon-cleanup' type ReadyPayload = { type: 'orca_server_ready' @@ -70,7 +71,7 @@ async function main(): Promise { if (activeServer?.child.exitCode === null) { await stopServer(activeServer).catch(() => activeServer?.child.kill('SIGKILL')) } - await stopOwnedDaemon() + await stopNodeServerVerifierDaemons(dataPath) rmSync(ownedRoot, { force: true, maxRetries: 20, recursive: true, retryDelay: 100 }) } } @@ -277,42 +278,6 @@ async function call( return response.result } -async function stopOwnedDaemon(): Promise { - const daemonPath = join(dataPath, 'daemon') - if (!existsSync(daemonPath)) { - return - } - const stoppedPids: number[] = [] - for (const entry of readdirSync(daemonPath)) { - if (!/^daemon-v\d+\.pid$/.test(entry)) { - continue - } - try { - const record = JSON.parse(readFileSync(join(daemonPath, entry), 'utf8')) as { pid?: unknown } - if (typeof record.pid === 'number' && Number.isInteger(record.pid)) { - process.kill(record.pid, 'SIGTERM') - stoppedPids.push(record.pid) - } - } catch { - // The isolated verifier directory can contain a daemon that already retired. - } - } - await Promise.all(stoppedPids.map(waitForProcessExit)) -} - -async function waitForProcessExit(pid: number): Promise { - const deadline = Date.now() + 10_000 - while (Date.now() < deadline) { - try { - process.kill(pid, 0) - } catch { - return - } - await new Promise((resolveWait) => setTimeout(resolveWait, 100)) - } - throw new Error(`terminal daemon ${pid} did not exit after SIGTERM`) -} - function assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message) diff --git a/config/scripts/node-server-verifier-daemon-cleanup.ts b/config/scripts/node-server-verifier-daemon-cleanup.ts new file mode 100644 index 00000000000..f3e9706aa33 --- /dev/null +++ b/config/scripts/node-server-verifier-daemon-cleanup.ts @@ -0,0 +1,70 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { DaemonClient } from '../../src/main/daemon/client' +import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner' + +export async function stopNodeServerVerifierDaemons(dataPath: string): Promise { + const daemonPath = join(dataPath, 'daemon') + if (!existsSync(daemonPath)) { + return + } + const shutdowns: Promise[] = [] + for (const entry of readdirSync(daemonPath)) { + const match = /^daemon-v(\d+)\.pid$/.exec(entry) + if (!match) { + continue + } + try { + const record = JSON.parse(readFileSync(join(daemonPath, entry), 'utf8')) as { pid?: unknown } + if (typeof record.pid === 'number' && Number.isInteger(record.pid)) { + shutdowns.push(shutdownDaemon(daemonPath, Number(match[1]), record.pid)) + } + } catch { + // The isolated verifier directory can contain a daemon that already retired. + } + } + await Promise.all(shutdowns) +} + +async function shutdownDaemon( + daemonPath: string, + protocolVersion: number, + pid: number +): Promise { + const client = new DaemonClient({ + socketPath: getDaemonSocketPath(daemonPath, protocolVersion), + tokenPath: getDaemonTokenPath(daemonPath, protocolVersion), + protocolVersion + }) + let requestError: unknown + try { + await client.ensureConnectedWithin(5_000) + await client.request('shutdown', { killSessions: true }, 10_000) + } catch (error) { + requestError = error + } finally { + client.disconnect() + } + + try { + await waitForProcessExit(pid) + } catch (error) { + if (requestError) { + throw new AggregateError([requestError, error], `terminal daemon ${pid} cleanup failed`) + } + throw error + } +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + try { + process.kill(pid, 0) + } catch { + return + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)) + } + throw new Error(`terminal daemon ${pid} did not exit after the shutdown request`) +} diff --git a/docs/reference/npm-server-design.md b/docs/reference/npm-server-design.md index ffc6cd88c2f..0b75a9eba4b 100644 --- a/docs/reference/npm-server-design.md +++ b/docs/reference/npm-server-design.md @@ -34,6 +34,7 @@ agents, and orchestration. The missing boundary is the process environment aroun - Keep one implementation of workspace, terminal, Git, file, agent, and orchestration behavior. - Make zero-argument startup useful over SSH without exposing a public interface by accident. - Keep desktop `orca serve` working during migration. +- Let the npm host and desktop app coexist safely as distinct hosts. - Support folder workspaces and Git worktrees. - Preserve macOS, Linux, Windows, SSH, WSL, Git 2.25, and glibc 2.31 compatibility. @@ -45,6 +46,8 @@ agents, and orchestration. The missing boundary is the process environment aroun - Making a client-local fallback browser part of the remote host or giving it remote ownership. - Automatically configuring a VPN, firewall, reverse proxy, or public network listener. - Shipping an Electron binary inside an npm wrapper. +- Letting two processes write one profile or automatically handing an npm host to the desktop app + in the initial release. ## User experience @@ -169,6 +172,31 @@ device identity. Every listener, socket, timer, and signal handler installed by the Node composition root has a matching cleanup path. Startup failure runs the same cleanup before exiting nonzero. +## Desktop coexistence and handoff + +The initial npm host and desktop app use separate default data directories. They can run on the +same machine without sharing writable state, but they appear as separate Orca hosts and opening +the desktop app does not take ownership of the npm host. + +The intended follow-up is a coordinated handoff, not an attempt to turn the running Node process +into Electron. Standalone-backend architectures keep the host process independent and let desktop +clients attach to or supervise it. Orca can move toward that model while keeping browser panes +desktop-local. + +A safe handoff requires: + +1. one cross-process profile lease with an owner identity and generation; +2. an authenticated local request from the desktop app to the npm host; +3. a host checkpoint followed by listener shutdown and explicit lease release; +4. desktop acquisition of the next generation before it opens the profile; +5. reuse of the device registry, E2EE keypair, runtime metadata, and durable terminal daemon; +6. client reconnect without re-pairing and terminal input/output after daemon adoption; +7. bounded rollback to the npm host if desktop startup fails before readiness. + +No implementation may copy a live profile, infer ownership from a PID file alone, or let both +processes publish runtime metadata. A desktop-only browser backend must remain a capability of the +desktop owner rather than silently executing remote browser work on a viewing client. + ## Packaging gates The publishable tarball must: @@ -255,6 +283,17 @@ the terminal marker. - [x] Link the shorter npm-server documentation from the existing Linux guide. - [x] Capture visual proof for the UI change. +### Desktop coexistence and handoff + +- [x] Use separate npm and desktop profiles so both installations can run safely today. +- [x] Document the ownership, identity, daemon, and rollback contract for a future handoff. +- [ ] Add a cross-process profile lease with generation fencing. +- [ ] Let desktop discover and authenticate to the local npm host. +- [ ] Checkpoint and transfer ownership without two live profile writers. +- [ ] Preserve pairing identity and daemon-backed terminal continuity across the handoff. +- [ ] Add failure-before-yield, failure-after-yield, mixed-version, and rollback oracles. +- [ ] Add packaged macOS, Linux, and Windows desktop-handoff journeys. + ### Tests - [x] Unit-test CLI parsing, address discovery, paths, readiness, and shutdown.