diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 10fbfd0b5e0..be3fc61fa8c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-12", + "updatedAt": "2026-07-13", "policy": { "maturityLevels": [ "experimental", @@ -16,6 +16,165 @@ } }, "gates": [ + { + "id": "runtime.headless-desktop-promotion-continuity", + "title": "Headless serve opens its desktop without replacing live terminal sessions", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "electron-runtime-contract", + "surfaces": [ + "headless orca serve", + "single-instance desktop activation", + "CLI open", + "persistent terminal reattach" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh" + ], + "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, and SSH identity transfer. A macOS Electron journey starts one headless owner in an isolated profile, creates and writes to a daemon PTY, activates the GUI through a second process, and verifies the original owner/runtime/daemon/PTY identities plus pre- and post-promotion I/O. Live packaged, Linux, and Windows journeys remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8457" + ], + "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation opens a window in that same process only after the persistent PTY provider and runtime RPC are ready; every live persisted local or SSH terminal remains bound to the same PTY/session, and a committed Cmd+Q still exits the promoted app.", + "oracle": "Unit tests coalesce early activation, fail closed on a fallback local provider, preserve the production single-instance path for serve, expose explicit desktop window state to local and remote clients, persist headless tab/leaf bindings, transfer local and SSH reattach metadata, and retain quit intent. The Electron journey asserts one main-process PID, runtime id, daemon PID, and PTY id across activation; confirms output written before promotion is visible afterward; confirms new terminal input still works; and requires the activating second process to exit.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec electron-vite build --mode e2e", + "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1" + ], + "testFiles": [ + "src/main/startup/serve-desktop-activation.test.ts", + "src/main/startup/serve-desktop-activation-wiring.test.ts", + "src/main/startup/single-instance-lock.test.ts", + "src/main/startup/window-all-closed-quit-policy.test.ts", + "src/cli/runtime-client.test.ts", + "src/cli/runtime/websocket-transport.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "tests/e2e/headless-serve-desktop-activation.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/startup/serve-desktop-activation.test.ts", + "assertions": [ + "early activation requests coalesce until the persistent provider is ready", + "a blocked provider drops pending activation and never opens a window" + ] + }, + { + "file": "src/main/startup/serve-desktop-activation-wiring.test.ts", + "assertions": [ + "second-instance and macOS app activation use the same safety gate", + "headless PTY registration waits for provider settlement and promotion waits for RPC startup" + ] + }, + { + "file": "src/main/startup/single-instance-lock.test.ts", + "assertions": [ + "serve never skips the single-instance lock even in development", + "the isolated E2E profile can opt into the production ownership path" + ] + }, + { + "file": "src/main/startup/window-all-closed-quit-policy.test.ts", + "assertions": [ + "a promoted serve owner remains alive after an ordinary window close but exits after a committed quit" + ] + }, + { + "file": "src/cli/runtime-client.test.ts", + "assertions": [ + "local open activates a reachable headless owner and waits for a desktop window", + "unsafe promotion returns an explicit blocked error instead of launching a second owner" + ] + }, + { + "file": "src/cli/runtime/websocket-transport.test.ts", + "assertions": [ + "remote-paired open reports remote desktop state without launching a local app" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "the headless sentinel transfers authority to the first real window", + "headless local and SSH PTY bindings are persisted on first promotion and later windowless reattach without changing ordinary desktop spawn persistence", + "status distinguishes available, openable, initializing, and blocked desktop states", + "desktop-only bell, command, and link scanners remain disabled until a real renderer graph is ready" + ] + }, + { + "file": "tests/e2e/headless-serve-desktop-activation.spec.ts", + "assertions": [ + "desktop activation keeps the same main owner PID, runtime id, daemon PID, and PTY id", + "terminal output written before promotion remains visible and post-promotion input/output still works", + "the activating second process exits instead of becoming another owner" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 13, + "summary": "Seven activation, ownership, quit, local/remote CLI, and runtime contract files passed with 704 tests, including first and repeated windowless reattach, local/SSH identity transfer, ordinary desktop persistence isolation, and dynamic side-effect scanner gating." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 52, + "summary": "The isolated Electron journey passed repeatedly on the final source; the latest 51.6-second run retained the same main owner, runtime, daemon, and PTY, restored pre-promotion output, accepted post-promotion input, and observed the activating process exit." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused unit contracts plus one isolated Electron headless-to-desktop journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic contracts and two consecutive local macOS Electron passes; CI and cross-platform soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The first Electron red run proved that bypassing the dev single-instance lock created a second owner. After enforcing one owner, the next red run retained owner/runtime/daemon identity but exposed a new PTY because the headless tab/leaf binding was not persisted. The final implementation persists that binding before renderer hydration and both subsequent Electron runs kept the original PTY and transcript. Focused unit tests were also observed red before the activation gate, promotion metadata transfer, and headless spawn-persistence changes were added." + }, + "performanceBudget": { + "required": false, + "evidence": "Activation adds no polling in the app owner and performs one bounded pass over live PTY records plus persisted terminal bindings only when a headless owner opens its first window. Desktop-only bell, command, PR-link, and mode scanners are rebuilt only while a real renderer graph is ready, preserving the prior pure-headless output path. CLI open keeps its existing 250ms bounded startup poll." + }, + "promotionCriteria": [ + "Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.", + "Add live packaged activation coverage on macOS plus representative Linux and Windows single-instance journeys.", + "Add an Electron SSH promotion journey in addition to the deterministic identity-transfer unit contract." + ], + "knownGaps": [ + "The Electron journey uses an isolated development bundle rather than the installed application so it cannot disturb a real user session.", + "Linux and Windows single-instance activation have unit coverage but no live Electron evidence yet.", + "SSH identity transfer is deterministic unit coverage only; the live Electron journey currently exercises the local daemon provider." + ], + "demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", diff --git a/docs/superpowers/plans/2026-07-13-headless-serve-gui-activation.md b/docs/superpowers/plans/2026-07-13-headless-serve-gui-activation.md new file mode 100644 index 00000000000..ef841fb283d --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-headless-serve-gui-activation.md @@ -0,0 +1,110 @@ +# Headless Serve to Desktop Activation Implementation Plan + +**Goal:** Safely activate a desktop window from the existing headless serve owner without restarting +the runtime, daemon, PTY sessions, or agents. + +**Architecture:** Make serve adopt the persistent daemon before becoming ready, gate desktop +activation on that provider contract, transfer runtime graph authority from a named headless +sentinel to the first real window, and make local `orca open` verify desktop availability instead of +RPC reachability. + +## Task 1: Lock the lifecycle contract with failing tests + +**Files:** + +- Add `src/main/startup/serve-desktop-activation.test.ts` +- Modify `src/main/runtime/orca-runtime.test.ts` +- Modify `src/cli/runtime-client.test.ts` +- Add `src/main/startup/serve-desktop-activation-wiring.test.ts` + +**Steps:** + +1. Test deferred activation coalescing, ready drain, and fail-closed behavior. +2. Test graph authority transfer from headless sentinel to a real BrowserWindow. +3. Test desktop status reporting and local/remote CLI open decisions. +4. Add a source-level wiring regression for serve startup order and removal of unsafe direct open. +5. Run the focused tests and record the expected RED failures. + +## Task 2: Implement activation state and status contracts + +**Files:** + +- Add `src/main/startup/serve-desktop-activation.ts` +- Modify `src/shared/runtime-types.ts` +- Modify `src/main/runtime/orca-runtime.ts` +- Modify `src/main/index.ts` + +**Steps:** + +1. Add the activation state machine and named headless window sentinel. +2. Report `available`, `openable`, `initializing`, or `blocked` from runtime status. +3. Transfer authority through renderer-reloading before the promoted renderer publishes. +4. Route second-instance and app activation through the gate. + +## Task 3: Make serve and desktop share the persistent provider contract + +**Files:** + +- Modify `src/main/index.ts` +- Update focused startup/runtime tests as required + +**Steps:** + +1. Start the daemon and hook barrier in both modes. +2. Await the local-PTY barrier before headless IPC registration. +3. Mark serve activation ready only for a persistent provider; otherwise mark blocked. +4. Install terminal side-effect delivery in both modes while keeping desktop-only scanners disabled + until a real renderer graph is ready. +5. Confirm no promotion path invokes daemon initialization a second time. + +## Task 4: Fix `orca open` + +**Files:** + +- Modify `src/cli/runtime/client.ts` +- Modify `src/cli/runtime/status.ts` +- Modify `src/cli/format.ts` +- Modify `src/main/ssh/ssh-remote-orca-cli.ts` +- Modify `src/main/ssh/ssh-remote-cli-format.ts` +- Update relevant tests + +**Steps:** + +1. Thread desktop status through local and remote CLI status shapes. +2. Mock the existing launch boundary for deterministic tests. +3. Always activate the local application and wait for `available`. +4. Return a specific error for `blocked` and a safe timeout for old/unknown runtimes. +5. Preserve remote-pairing behavior without launching a local app. + +## Task 5: Add isolated Electron lifecycle smoke + +**Files:** + +- Add `tests/e2e/headless-serve-desktop-activation.spec.ts` +- Modify `config/reliability-gates.jsonc` + +**Steps:** + +1. Launch serve and a disposable RPC-created terminal in an isolated profile. +2. Activate the same app normally and observe the window on the original owner. +3. Assert runtime and PTY identity continuity plus post-promotion input/output. +4. Record exact macOS evidence and cross-platform gaps in the reliability gate. + +## Task 6: Verify and submit + +**Checks:** + +1. Focused RED/GREEN tests. +2. Focused isolated Electron smoke twice. +3. `pnpm run typecheck`. +4. `pnpm run lint`. +5. `pnpm run build:desktop`. +6. `git diff --check` and final diff review. +7. Push an independent fork branch, open an upstream PR linked to #8457, and wait for green CI. + +**Failure handling:** Any evidence of daemon restart, PTY id replacement, new agent launch, second +profile owner, or renderer promotion while the persistent provider is unavailable blocks the PR. + +**Known verification limits:** Native Windows/WSL/SSH live activation cannot be reproduced on this +macOS host; keep those as explicit reliability gaps and rely on pure cross-platform policy tests plus +CI build/type coverage. diff --git a/docs/superpowers/specs/2026-07-13-headless-serve-gui-activation-design.md b/docs/superpowers/specs/2026-07-13-headless-serve-gui-activation-design.md new file mode 100644 index 00000000000..21be235ec14 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-headless-serve-gui-activation-design.md @@ -0,0 +1,147 @@ +# Headless Serve to Desktop Activation Design + +## Problem + +A packaged `orca serve` process uses the normal user-data profile and owns Electron's +single-instance lock. After the desktop process exits, that headless owner can keep the runtime +reachable while no desktop window exists. Current activation paths then fail in opposite ways: + +- `orca open` returns as soon as any runtime answers, so it never asks macOS, Windows, or Linux to + reveal a desktop window. +- Finder, Dock, or a second application launch reaches the normal `second-instance` callback. The + callback opens a BrowserWindow inside the serve process even though serve skipped the desktop + PTY startup contract and published window id `0` as the authoritative graph owner. + +The unsupported transition can make the renderer reconnect through the in-process local PTY +provider instead of the surviving daemon. Persisted panes then appear missing and provider resume +commands may start replacement or duplicate agents. + +## Safety invariants + +1. One process remains the owner of the user-data profile, runtime metadata, hook endpoint, and + single-instance lock throughout activation. +2. A serve process may open a desktop window only after it has adopted the persistent daemon PTY + provider. If that provider cannot be established, activation fails closed and no renderer is + created. +3. The headless graph sentinel transfers authority to the first real BrowserWindow before that + renderer publishes its graph. After a windowless gap, the next window checkpoints live bindings + before reclaiming authority; concurrently attached windows cannot steal it. +4. Activation never restarts the runtime RPC server, daemon, or agent processes. +5. A CLI `open` command succeeds only after a desktop window is reported available. Runtime + reachability alone is insufficient. +6. Repeated activation requests during startup coalesce into one deferred attempt; blocked + activation never becomes a latent request that opens unexpectedly later. +7. Remote-paired CLI clients do not launch a desktop application on the client machine. + +## Rejected alternatives + +### Let serve bypass the single-instance lock + +Two Electron processes would share the same profile and race `orca-runtime.json`, agent-hook +endpoint files, Chromium profile state, and daemon bindings. The newest writer could leave stale +metadata when it exits. This recreates the ownership ambiguity that the lock prevents. + +### Open the window but keep serve on LocalPtyProvider + +This is the observed failure. Existing daemon session ids are absent from the local provider, so +renderer reattach can treat live agents as expired and issue replacement resume commands. + +### Always promote even after daemon startup failure + +If serve has already spawned local PTYs, the first renderer load's local orphan sweep can terminate +them. The safe response is an explicit blocked status and no BrowserWindow. + +### Terminate serve and start a new desktop process + +That requires a cross-process lock, runtime metadata, hook endpoint, daemon checkpoint, and CLI +parent handoff protocol. In-place promotion has one owner and can preserve the already-connected +daemon adapter, so it has a smaller interruption surface. + +## Design + +### 1. Shared startup services + +Run the existing daemon PTY provider and agent-hook startup barrier for both desktop and serve +modes. Serve waits for the local-PTY barrier before it registers headless PTY IPC or reports ready. +This ensures any session created or adopted by serve uses the same persistent provider that the +desktop renderer will later use. + +The runtime's terminal-side-effect callback is installed in both modes, but desktop-only bell, +command, link, and mode scanners remain disabled until a real renderer graph is ready. They are +disabled again when that graph disappears, so a long-lived pure headless process retains its +existing output cost. + +### 2. Serve activation gate + +A small process-local state machine owns `initializing`, `ready`, and `blocked`: + +- activation while `initializing` records one pending request; +- entering `ready` runs that request once; +- entering `blocked` drops it and reports a diagnostic; +- activation while `ready` runs the existing focus-or-open policy; +- activation while `blocked` never calls `openMainWindow`. + +Serve enters `ready` only when the local provider is no longer the in-process +`LocalPtyProvider`, headless IPC is registered, the runtime graph is published, and RPC startup has +completed. A daemon failure leaves serve functional as a headless fallback but marks desktop +activation `blocked`. + +Both Electron `second-instance` and macOS `activate` events route through this gate. Normal desktop +startup uses the same gate in the ready state. + +### 3. Graph authority transfer + +Use a named `HEADLESS_RUNTIME_WINDOW_ID` sentinel. When `attachWindow(realId)` sees that sentinel, +the runtime enters the existing renderer-reloading state, advances the graph epoch, preserves live +PTY state, and transfers authority to `realId`. The renderer can then publish normally. A second +real window remains non-authoritative. + +### 4. Desktop status contract and CLI open + +`status.get` reports a backward-compatible desktop status: + +- `available`: a real BrowserWindow owns the runtime graph; +- `openable`: activation can safely create or reveal a window; +- `initializing`: serve is still establishing the persistent provider; +- `blocked`: serve cannot safely promote. + +Local `orca open` always launches/activates the installed application, even if runtime RPC is +already reachable, then polls until status becomes `available`. It returns a specific blocked error +instead of recommending `open -n`. Remote-paired clients keep their current no-local-launch +behavior. + +## Verification + +### Unit and contract tests + +- activation gate queues once, drains once, and blocks without invoking open; +- runtime transfers authority from the headless sentinel to one real window and enters reloading; +- live local and SSH bindings are persisted before first promotion and later windowless reattach, + while ordinary desktop background creation keeps its existing opt-in persistence behavior; +- desktop-only output scanners stay disabled until a real renderer graph is ready; +- status distinguishes headless, openable, blocked, and available states; +- CLI open activates a reachable headless runtime, waits for `available`, handles blocked state, + focuses an already-running desktop, and never launches for remote pairing; +- source wiring proves serve starts and awaits the persistent provider before headless PTY + registration and does not retain the old direct-open path. + +### Isolated Electron lifecycle smoke + +Using a disposable user-data directory: + +1. launch built Electron in serve mode; +2. add a disposable repository and create a terminal through runtime RPC; +3. start a long-running command and capture runtime id, daemon session id, and provider process + evidence; +4. launch the same app normally against the same profile; +5. assert the original serve process creates the only desktop window; +6. assert runtime id and PTY id stay unchanged and no replacement PTY appears; +7. send input after promotion and observe output from the same session. + +The smoke must never use the user's installed profile or current agent sessions. + +## Scope + +This change fixes lifecycle ownership and safe desktop activation. Resource Manager false-positive +cleanup is handled independently by #8459 / PR #8467. LAN reachability of a server intentionally +bound to loopback remains outside this change. diff --git a/src/cli/format.ts b/src/cli/format.ts index 80c621418e8..9d608a5b0f4 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -168,6 +168,7 @@ export function formatCliStatus(status: CliStatusResult): string { return [ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 7243d19fa94..5ee5a4bf752 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -2,13 +2,19 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client' +import { launchOrcaApp } from './runtime/launch' + +vi.mock('./runtime/launch', () => ({ + launchOrcaApp: vi.fn() +})) const servers = new Set>() const sockets = new Set() afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() for (const socket of sockets) { socket.destroy() } @@ -170,7 +176,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.graph.state).toBe('unavailable') }) - it('openOrca succeeds immediately when the runtime is already reachable', async () => { + it('openOrca activates the app even when a desktop runtime is already reachable', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { @@ -204,6 +210,86 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.runtime.state).toBe('ready') expect(status.result.runtime.reachable).toBe(true) + expect(launchOrcaApp).toHaveBeenCalledOnce() + }) + + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + let statusRequests = 0 + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + statusRequests += 1 + const available = statusRequests > 1 + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: available ? 1 : 0, + graphStatus: available ? 'reloading' : 'ready', + authoritativeWindowId: available ? 1 : 0, + desktopWindowStatus: available ? 'available' : 'initializing', + liveTabCount: 0, + liveLeafCount: 0 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + const status = await client.openOrca(1_000) + + expect(launchOrcaApp).toHaveBeenCalledOnce() + expect(status.result.app.desktopWindowStatus).toBe('available') + expect(statusRequests).toBeGreaterThan(1) + }) + + it('openOrca fails explicitly when the serve owner cannot promote safely', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: 0, + desktopWindowStatus: 'blocked', + liveTabCount: 1, + liveLeafCount: 1 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + + await expect(client.openOrca(100)).rejects.toMatchObject({ + code: 'desktop_activation_blocked' + }) + expect(launchOrcaApp).toHaveBeenCalledOnce() }) it('times out if the runtime never responds', async () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9211173be2..57ab3f3e480 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -115,7 +115,10 @@ export class RuntimeClient { // that this client machine has a local Orca desktop process. app: { running: false, - pid: null + pid: null, + ...(response.result.desktopWindowStatus + ? { desktopWindowStatus: response.result.desktopWindowStatus } + : {}) }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -169,15 +172,25 @@ export class RuntimeClient { async openOrca(timeoutMs = 15_000): Promise> { const initial = await this.getCliStatus() - if (initial.result.runtime.reachable) { + if (this.remotePairing) { return initial } launchOrcaApp() + if (initial.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + if (initial.result.app.desktopWindowStatus === 'available') { + return initial + } + const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() - if (status.result.runtime.reachable) { + if (status.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + if (status.result.app.desktopWindowStatus === 'available') { return status } await delay(250) @@ -185,11 +198,18 @@ export class RuntimeClient { throw new RuntimeClientError( 'runtime_open_timeout', - 'Timed out waiting for Orca to start. Run the Orca app manually and try again.' + 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' ) } } +function throwDesktopActivationBlocked(): never { + throw new RuntimeClientError( + 'desktop_activation_blocked', + 'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.' + ) +} + function resolveRemotePairing( userDataPath: string, pairingCode: string | null, diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index dff8c231fc9..7ec56e11db7 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -35,10 +35,12 @@ export async function getCliStatus( throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) return buildCliStatusResponse({ app: { running: true, - pid: metadata.pid + pid: metadata.pid, + ...(desktopWindowStatus ? { desktopWindowStatus } : {}) }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -68,6 +70,19 @@ export async function getCliStatus( } } +function resolveDesktopWindowStatus( + status: RuntimeStatus +): CliStatusResult['app']['desktopWindowStatus'] { + if (status.desktopWindowStatus) { + return status.desktopWindowStatus + } + // Why: older desktop runtimes predate the explicit status but a positive + // Electron id still proves that a real window owns the graph. + return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0 + ? 'available' + : undefined +} + function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { return { id: 'local-status', diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 6a60c97b310..364fc05c508 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from 'node:http' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocketServer } from 'ws' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { @@ -13,6 +13,7 @@ import { publicKeyToBase64 } from '../../shared/e2ee-crypto' import { RuntimeClient } from './client' +import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { @@ -20,6 +21,10 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' +vi.mock('./launch', () => ({ + launchOrcaApp: vi.fn() +})) + type TestRuntime = { endpoint: string publicKeyB64: string @@ -31,6 +36,7 @@ describe('CLI remote WebSocket transport', () => { const servers: TestRuntime[] = [] afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() await Promise.all(servers.splice(0).map((server) => server.close())) }) @@ -79,6 +85,28 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') }) + it('does not launch a local desktop app for remote-paired open', async () => { + const runtime = await startTestRuntime('runtime-remote-headless', { + desktopWindowStatus: 'initializing' + }) + servers.push(runtime) + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + const status = await client.openOrca() + + expect(status.result.app.desktopWindowStatus).toBe('initializing') + expect(launchOrcaApp).not.toHaveBeenCalled() + }) + it('connects through a saved environment selector', async () => { const runtime = await startTestRuntime('runtime-env-1') servers.push(runtime) @@ -128,6 +156,7 @@ async function startTestRuntime( statusOverrides: { runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number + desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -177,6 +206,7 @@ async function startTestRuntime( rendererGraphEpoch: 1, graphStatus: 'ready', authoritativeWindowId: null, + desktopWindowStatus: statusOverrides.desktopWindowStatus, liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: diff --git a/src/main/index.ts b/src/main/index.ts index 16d3c870c2f..966f47e9eb0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -91,7 +91,8 @@ import { acquireSingleInstanceLock, logSingleInstanceLockBypass, logSingleInstanceLockFailure, - shouldBypassSingleInstanceLock + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock } from './startup/single-instance-lock' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' @@ -102,6 +103,7 @@ import { } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' +import { createServeDesktopActivationGate } from './startup/serve-desktop-activation' import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' @@ -182,6 +184,11 @@ import { import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../shared/runtime-types' +import { LocalPtyProvider } from './providers/local-pty-provider' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -236,6 +243,16 @@ let gpuFallbackActiveThisLaunch = false let localPtyStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') +const desktopActivationGate = createServeDesktopActivationGate({ + initialState: isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) +}) // Why: on Windows a CLI-shaped launch (Orca.exe ) that lost // ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance // lock to a running window, and exit silently. Redirect it to node mode here, @@ -453,6 +470,23 @@ function focusExistingWindow(): void { }) } +function requestDesktopActivation(): void { + desktopActivationGate.requestActivation() +} + +function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const state = desktopActivationGate.getState() + return state === 'ready' ? 'openable' : state +} + +function settleServeDesktopActivation(): void { + if (getLocalPtyProvider() instanceof LocalPtyProvider) { + desktopActivationGate.markBlocked('persistent PTY provider unavailable') + return + } + desktopActivationGate.markReady() +} + // Why: a webContents-scoped flag that auto-expires so an intent set for one renderer // can't leak to a later load. `consume` clears on a positive match for one-shot // signals (the recovery reload fires exactly one did-finish-load). @@ -543,22 +577,25 @@ const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode }) +const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ + isDev: is.dev, + isServeMode +}) if (bypassSingleInstanceLock) { // Why: this is an explicit diagnostic escape hatch for macOS builds where // Electron reports a false lock loss before any normal app logs exist. logSingleInstanceLockBypass() } -const hasSingleInstanceLock = - is.dev && !isServeMode +const hasSingleInstanceLock = skipSingleInstanceLock + ? true + : bypassSingleInstanceLock ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, focusExistingWindow) + : acquireSingleInstanceLock(app, requestDesktopActivation) if (startupDiagnosticsEnabled) { logStartupDiagnostic('single-instance-lock-result', { acquired: hasSingleInstanceLock, bypassed: bypassSingleInstanceLock, - skippedForDev: is.dev && !isServeMode + skippedForDev: skipSingleInstanceLock }) } if (!hasSingleInstanceLock) { @@ -623,12 +660,11 @@ ipcMain.handle( } ) -function startDesktopFirstWindowStartupServices(): Promise { +function startTerminalRuntimeStartupServices(): Promise { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is desktop-only. Headless `orca serve` - // registers its PTY runtime separately and must not spawn the desktop daemon - // or hook loopback listener. + // Why: desktop and headless serve must adopt the same persistent provider + // before either path is allowed to create terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) await initDaemonPtyProvider(signal) @@ -637,6 +673,9 @@ function startDesktopFirstWindowStartupServices(): Promise { // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so // the renderer awaits this barrier before restored terminals reconnect. startAgentHookServer: async () => { + if (!isAgentStatusHooksEnabled(store?.getSettings())) { + return + } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', @@ -677,23 +716,6 @@ function startDesktopFirstWindowStartupServices(): Promise { return firstWindowStartupServicesReady } -async function startServeAgentHookServer(): Promise { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - try { - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - } catch (error) { - // Why: remote hook callbacks enrich agent status only. A headless runtime - // should still serve terminals if the loopback receiver cannot bind. - console.error('[agent-hooks] Failed to start serve hook server:', error) - } -} - function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hookTarget = @@ -1747,20 +1769,14 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, - // Why: derived title/bell/agent facts ride one batched main→renderer - // channel (terminal-side-effect-authority.md). The renderer's authority - // kill switch decides whether to consume. Headless serve never creates a - // window, so the dep is omitted entirely — the runtime then skips fact - // batch construction and the per-chunk bell walk. - ...(isServeMode - ? {} - : { - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - } - }), + // Why: serve can be promoted in place, so keep the listener wired from + // startup; runtime enables desktop-only scanners only for a ready renderer. + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus: getDesktopWindowStatus, // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), @@ -2051,12 +2067,13 @@ app.whenReady().then(async () => { }) registerMobileHandlers(runtimeRpc) - if (!isServeMode) { - startDesktopFirstWindowStartupServices() - } + startTerminalRuntimeStartupServices() + app.on('activate', requestDesktopActivation) if (serveOptions) { - await startServeAgentHookServer() + // Why: headless PTYs must never start on the fallback provider and then be + // swept when an activated renderer registers desktop lifecycle handlers. + await localPtyStartupReady registerHeadlessPtyRuntime( runtime, prepareCodexRuntimeHomeForLaunch, @@ -2074,11 +2091,12 @@ app.whenReady().then(async () => { // Why: headless servers have no renderer graph publisher. Publish an // explicit empty graph so status clients see a ready server while // renderer-only operations still fail at their own window boundary. - runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) + settleServeDesktopActivation() installServeSignalHandlers() // Why: the orca CLI command is normally installed by the renderer onboarding / // Settings "Install CLI" flow via the cli:install IPC. Headless serve has no @@ -2161,15 +2179,6 @@ app.whenReady().then(async () => { triggerStartupNotificationRegistration(store) } }) - - app.on('activate', () => { - // Don't re-open a window while Squirrel's ShipIt is replacing the .app - // bundle. Without this guard the old version gets resurrected and the - // update never applies. - if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) { - openMainWindow() - } - }) }) app.on('before-quit', () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 03a55a02a19..af128652064 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,7 +56,10 @@ import { type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -1453,6 +1456,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus()).toMatchObject({ graphStatus: 'unavailable', authoritativeWindowId: null, + desktopWindowStatus: 'openable', rendererGraphEpoch: 0 }) expect(runtime.getRuntimeId()).toBeTruthy() @@ -1547,6 +1551,118 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID) }) + it('transfers authority from the headless sentinel to the first real window', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) => + windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null + ) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.attachWindow(TEST_WINDOW_ID) + runtime.attachWindow(2) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + desktopWindowStatus: 'available', + graphStatus: 'reloading', + rendererGraphEpoch: 1 + }) + }) + + it('marks live headless PTYs for renderer reattach before desktop promotion', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('marks live bindings again when reopening after a promoted window closes', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.attachWindow(TEST_WINDOW_ID) + ;(runtimeStore.setWorkspaceSession as unknown as (next: WorkspaceSessionState) => void)({ + ...getSession(), + activeWorktreeIdsOnShutdown: [] + }) + runtime.markGraphUnavailable(TEST_WINDOW_ID) + + runtime.attachWindow(2) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('preserves live SSH session identities when promoting a headless runtime', () => { + const remotePtyId = 'ssh:ssh-1@@persisted-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [], + activeConnectionIdsAtShutdown: [], + remoteSessionIdsByTabId: {}, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: remotePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty(remotePtyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession()).toMatchObject({ + activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID], + activeConnectionIdsAtShutdown: ['ssh-1'], + remoteSessionIdsByTabId: { 'host-tab': remotePtyId } + }) + }) + + it('reports the activation gate state while no desktop window is available', () => { + const runtime = new OrcaRuntimeService(store, undefined, { + getDesktopWindowStatus: () => 'blocked' + }) + + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + expect(runtime.getStatus().desktopWindowStatus).toBe('blocked') + }) + it('bumps the epoch and enters reloading when the authoritative window reloads', () => { const runtime = createRuntime() @@ -6874,6 +6990,34 @@ describe('OrcaRuntimeService', () => { return { runtime, batches } } + it('defers desktop-only output scanners until a headless runtime is promoted', () => { + const { runtime, batches } = createSideEffectRuntime() + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.onPtyData('pty-1', '\x07', 100) + + expect(batches).toEqual([]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtyData('pty-1', '\x07', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: 'bell' }]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).not.toBeNull() + + runtime.markGraphUnavailable(1) + runtime.onPtyData('pty-1', '\x07', 102) + + expect(batches).toHaveLength(1) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -9931,11 +10075,37 @@ describe('OrcaRuntimeService', () => { }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - worktreeId: TEST_WORKTREE_ID + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true }) ) }) + it('keeps ordinary desktop background terminal persistence opt-in', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + const webContents = { send: vi.fn() } + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const spawnOptions = spawn.mock.calls[0]?.[0] as + | { persistHostSessionBinding?: boolean } + | undefined + expect(spawnOptions?.persistHostSessionBinding).toBeUndefined() + }) + it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 45f582a552b..bd463563f0e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -170,6 +170,10 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear-agent-access' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -2483,8 +2487,10 @@ export class OrcaRuntimeService { private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null + private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null + private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2518,6 +2524,7 @@ export class OrcaRuntimeService { // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] buildAgentHookPtyEnv?: () => Record + getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus } ) { this.store = store @@ -2544,6 +2551,7 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -2946,6 +2954,7 @@ export class OrcaRuntimeService { rendererGraphEpoch: this.rendererGraphEpoch, graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, + desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, @@ -3060,11 +3069,78 @@ export class OrcaRuntimeService { } attachWindow(windowId: number): void { + if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + // Why: promotion is a renderer reload of the same graph owner, not a new + // runtime; stale handles must transition before the real window publishes. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID) + this.authoritativeWindowId = windowId + return + } if (this.authoritativeWindowId === null) { + // Why: a promoted serve can close and later reopen its window while new + // background PTYs keep arriving; every windowless gap needs this handoff. + this.persistWindowlessPtyBindingsForDesktopAttach() this.authoritativeWindowId = windowId } } + private persistWindowlessPtyBindingsForDesktopAttach(): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const promotablePtys = [...this.ptysById.values()].filter((pty) => { + if (!pty.connected || !pty.tabId) { + return false + } + const tab = session.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + if (!tab) { + return false + } + const layoutPtyIds = Object.values( + session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} + ) + return tab.ptyId === pty.ptyId || layoutPtyIds.includes(pty.ptyId) + }) + if (promotablePtys.length === 0) { + return + } + + // Why: renderer hydration treats an explicitly-present shutdown list as + // authoritative. A windowless owner has no renderer shutdown pass, so seed + // that existing reattach contract before its next desktop window loads. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...promotablePtys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...promotablePtys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of promotablePtys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession({ + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }) + } + syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { if (this.authoritativeWindowId === null) { this.authoritativeWindowId = windowId @@ -3189,6 +3265,7 @@ export class OrcaRuntimeService { this.rebuildLeafPtyIndex() this.notifyMobileSessionTabSnapshots() this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) @@ -5951,7 +6028,7 @@ export class OrcaRuntimeService { /** Record one derived side-effect fact: batched per chunk while applying * bytes, emitted immediately for between-chunk facts (stale-title timer). */ private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { - if (!this.onTerminalSideEffects) { + if (!this.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable) { return } const entry = this.ptyTitleTrackersByPtyId.get(ptyId) @@ -5967,7 +6044,11 @@ export class OrcaRuntimeService { facts: TerminalSideEffectFact[], options: { replay?: boolean } = {} ): void { - if (!this.onTerminalSideEffects || facts.length === 0) { + if ( + !this.onTerminalSideEffects || + !this.terminalSideEffectConsumerAvailable || + facts.length === 0 + ) { return } const batch: TerminalSideEffectBatch = { @@ -6146,7 +6227,7 @@ export class OrcaRuntimeService { // Why: bell/command-finished/pr-link/2031 facts exist only for the // pty:sideEffect channel. Headless serve has no consumer, so skip the // per-chunk bell walk and 133/URL/2031 scans entirely. - ...(this.onTerminalSideEffects + ...(this.terminalSideEffectConsumerAvailable ? { onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -6179,7 +6260,7 @@ export class OrcaRuntimeService { // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. - commandCodeDetector: this.onTerminalSideEffects + commandCodeDetector: this.terminalSideEffectConsumerAvailable ? createCommandCodeOutputStatusDetector({ startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, onWorking: (prompt) => { @@ -6278,6 +6359,19 @@ export class OrcaRuntimeService { this.ptyTitleTrackersByPtyId.delete(ptyId) } + private setTerminalSideEffectConsumerAvailable(available: boolean): void { + const nextAvailable = available && this.onTerminalSideEffects !== null + if (nextAvailable === this.terminalSideEffectConsumerAvailable) { + return + } + this.terminalSideEffectConsumerAvailable = nextAvailable + // Why: optional bell/command/link scanners are selected when a tracker is + // created. Rebuild at the window boundary so pure headless output stays cheap. + for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { + this.disposePtyTitleTracker(ptyId) + } + } + private extractLastOsc7CwdForPty( ptyId: string, data: string @@ -17446,12 +17540,12 @@ export class OrcaRuntimeService { ): Promise { const presentation = resolveTerminalPresentation(opts) const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true + const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() // Why: pre-diff createTerminal fell back to the renderer's active worktree // when no selector was provided. The new background-spawn branch hard- // requires a resolvable selector, so route the no-selector case through // the renderer IPC path to preserve that behavior. - const rendererWindow = - opts.rendererBacked === true ? this.getAvailableAuthoritativeWindow() : null + const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && ((!requiresRendererFocus && opts.rendererBacked !== true) || @@ -17572,7 +17666,12 @@ export class OrcaRuntimeService { tabId, leafId, ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), - ...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {}) + // Why: a headless-created pane has no renderer session writer. Persist + // its tab/leaf binding at spawn so a later promoted window reattaches + // the live daemon or SSH PTY instead of replacing it with a fresh one. + ...(launchOpts.persistHostSessionBinding || availableAuthoritativeWindow === null + ? { persistHostSessionBinding: true } + : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) this.registerPty(result.id, workspace.id, workspace.connectionId) @@ -18894,6 +18993,7 @@ export class OrcaRuntimeService { // against whatever the renderer rebuilds next. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() @@ -18910,6 +19010,7 @@ export class OrcaRuntimeService { return } this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() } @@ -18923,6 +19024,7 @@ export class OrcaRuntimeService { this.rendererGraphEpoch += 1 } this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) this.authoritativeWindowId = null this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts index c654af79b74..744c9f8e6cf 100644 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ b/src/main/ssh/ssh-remote-cli-format.ts @@ -34,6 +34,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: stdout: `${[ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index d555b5c2707..c8a5189acb7 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -159,7 +159,11 @@ async function dispatchRemoteCli( } const status = response.result as RuntimeStatus const cliStatus: CliStatusResult = { - app: { running: true, pid: null }, + app: { + running: true, + pid: null, + ...(status.desktopWindowStatus ? { desktopWindowStatus: status.desktopWindowStatus } : {}) + }, runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts new file mode 100644 index 00000000000..e86e69b8372 --- /dev/null +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('serve desktop activation wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('routes second-instance and app activation through one safety gate', () => { + expect(source).toContain('createServeDesktopActivationGate({') + expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') + expect(source).toContain("app.on('activate', requestDesktopActivation)") + expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + }) + + it('settles the persistent provider before headless PTY registration', () => { + const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') + const startupIndex = source.indexOf( + '\n startTerminalRuntimeStartupServices()\n', + appReadyIndex + ) + const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) + const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) + const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + + expect(startupIndex).toBeGreaterThanOrEqual(0) + expect(startupIndex).toBeLessThan(serveIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex) + expect(source).not.toContain( + 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' + ) + }) + + it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { + const serveIndex = source.indexOf('if (serveOptions) {') + const sentinelIndex = source.indexOf( + 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', + serveIndex + ) + const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + + expect(sentinelIndex).toBeGreaterThan(serveIndex) + expect(rpcIndex).toBeGreaterThan(sentinelIndex) + expect(settleIndex).toBeGreaterThan(rpcIndex) + expect(source).not.toContain('runtime.syncWindowGraph(0,') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.test.ts b/src/main/startup/serve-desktop-activation.test.ts new file mode 100644 index 00000000000..659e7b382d4 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { createServeDesktopActivationGate } from './serve-desktop-activation' + +describe('createServeDesktopActivationGate', () => { + it('coalesces activation requests while serve is initializing and drains once when ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(gate.getState()).toBe('initializing') + + gate.markReady() + + expect(activateWindow).toHaveBeenCalledOnce() + expect(gate.getState()).toBe('ready') + }) + + it('activates immediately after the persistent provider is ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'ready', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).toHaveBeenCalledTimes(2) + }) + + it('drops pending activation and fails closed when promotion is blocked', () => { + const activateWindow = vi.fn() + const onBlocked = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow, + onBlocked + }) + + gate.requestActivation() + gate.markBlocked('persistent PTY provider unavailable') + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(onBlocked).toHaveBeenCalledTimes(2) + expect(gate.getState()).toBe('blocked') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.ts b/src/main/startup/serve-desktop-activation.ts new file mode 100644 index 00000000000..8ee6a83a0e8 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.ts @@ -0,0 +1,56 @@ +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' + +type ActivationGateState = Exclude | 'ready' + +export type ServeDesktopActivationGate = { + getState: () => ActivationGateState + requestActivation: () => void + markReady: () => void + markBlocked: (reason: string) => void +} + +export function createServeDesktopActivationGate(options: { + initialState: 'initializing' | 'ready' + activateWindow: () => void + onBlocked?: (reason: string) => void +}): ServeDesktopActivationGate { + let state: ActivationGateState = options.initialState + let pendingActivation = false + let blockedReason = 'desktop activation is unavailable' + + return { + getState: () => state, + requestActivation: () => { + if (state === 'ready') { + options.activateWindow() + return + } + if (state === 'initializing') { + pendingActivation = true + return + } + options.onBlocked?.(blockedReason) + }, + markReady: () => { + if (state !== 'initializing') { + return + } + state = 'ready' + if (pendingActivation) { + pendingActivation = false + options.activateWindow() + } + }, + markBlocked: (reason) => { + if (state !== 'initializing') { + return + } + state = 'blocked' + blockedReason = reason + if (pendingActivation) { + pendingActivation = false + options.onBlocked?.(blockedReason) + } + } + } +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index f5318b7b54b..5ccd70e6a4a 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -5,6 +5,7 @@ import { logSingleInstanceLockBypass, logSingleInstanceLockFailure, shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } from './single-instance-lock' @@ -73,6 +74,24 @@ describe('acquireSingleInstanceLock', () => { }) }) +describe('shouldSkipSingleInstanceLock', () => { + it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + }) + + it('lets isolated E2E exercise the production single-instance path', () => { + expect( + shouldSkipSingleInstanceLock({ + isDev: true, + isServeMode: false, + env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + }) + ).toBe(false) + }) +}) + describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 5fd9cc27610..078500284a2 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -4,6 +4,7 @@ import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startu export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK' +export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK' export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = '[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.' @@ -49,6 +50,15 @@ export function shouldBypassSingleInstanceLock(options: { ) } +export function shouldSkipSingleInstanceLock(options: { + env?: NodeJS.ProcessEnv + isDev: boolean + isServeMode: boolean +}): boolean { + const env = options.env ?? process.env + return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' +} + export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) } diff --git a/src/main/startup/window-all-closed-quit-policy.test.ts b/src/main/startup/window-all-closed-quit-policy.test.ts index b7aab9e13ae..6c451f7e4ad 100644 --- a/src/main/startup/window-all-closed-quit-policy.test.ts +++ b/src/main/startup/window-all-closed-quit-policy.test.ts @@ -41,4 +41,14 @@ describe('shouldQuitWhenAllWindowsClosed', () => { }) ).toBe(true) }) + + it('continues a committed quit after a serve owner was promoted to desktop', () => { + expect( + shouldQuitWhenAllWindowsClosed({ + platform: 'darwin', + isQuitting: true, + isServeMode: true + }) + ).toBe(true) + }) }) diff --git a/src/main/startup/window-all-closed-quit-policy.ts b/src/main/startup/window-all-closed-quit-policy.ts index 8b23f436140..f17881ea989 100644 --- a/src/main/startup/window-all-closed-quit-policy.ts +++ b/src/main/startup/window-all-closed-quit-policy.ts @@ -3,7 +3,7 @@ export function shouldQuitWhenAllWindowsClosed(options: { isQuitting: boolean isServeMode: boolean }): boolean { - if (options.isServeMode) { + if (options.isServeMode && !options.isQuitting) { return false } return options.platform !== 'darwin' || options.isQuitting diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 9f91e7dcc8e..bd8ee530e70 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -37,6 +37,12 @@ export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable' +export type RuntimeDesktopWindowStatus = 'available' | 'openable' | 'initializing' | 'blocked' + +// Why: headless serve still owns one runtime graph, but zero can never collide +// with Electron BrowserWindow ids and can be transferred safely on promotion. +export const HEADLESS_RUNTIME_WINDOW_ID = 0 + // Why: the access scope a paired device token grants. Lives in shared so // pairing offers, status.get, and the device registry use one vocabulary. export type DeviceScope = 'mobile' | 'runtime' @@ -55,6 +61,7 @@ export type RuntimeStatus = { rendererGraphEpoch: number graphStatus: RuntimeGraphStatus authoritativeWindowId: number | null + desktopWindowStatus?: RuntimeDesktopWindowStatus liveTabCount: number liveLeafCount: number // Why: optional so clients can read both new and pre-contract runtimes. @@ -85,6 +92,7 @@ export type CliStatusResult = { app: { running: boolean pid: number | null + desktopWindowStatus?: RuntimeDesktopWindowStatus } runtime: { state: CliRuntimeState diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts new file mode 100644 index 00000000000..74057740baf --- /dev/null +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -0,0 +1,185 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { _electron as electron, type ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' +import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args' +import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { + RuntimeStatus, + RuntimeTerminalCreate, + RuntimeTerminalRead +} from '../../src/shared/runtime-types' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' + +const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron') +const electronPath = path.join( + electronPackageDir, + 'dist', + readFileSync(path.join(electronPackageDir, 'path.txt'), 'utf8').trim() +) + +function createLaunchEnv(userDataDir: string): NodeJS.ProcessEnv { + const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env + void _unused + return { + ...cleanEnv, + NODE_ENV: 'development', + ORCA_E2E_USER_DATA_DIR: userDataDir, + ORCA_E2E_HEADLESS: '1', + // Why: production builds always use the lock; this opt-in makes the dev + // E2E bundle exercise the same second-instance ownership path. + ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' + } +} + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return true + } + return await new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timeout) + resolve(true) + } + const timeout = setTimeout(() => { + child.off('exit', onExit) + resolve(false) + }, timeoutMs) + child.once('exit', onExit) + }) +} + +test.describe.configure({ mode: 'serial' }) + +test('promotes the headless owner without replacing its daemon terminal', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both launches and intentionally opts out of the default app fixture. +{}) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') + const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-serve-promotion-')) + const env = createLaunchEnv(userDataDir) + let serveApp: ElectronApplication | null = null + let activatingProcess: ChildProcess | null = null + + writeFileSync( + path.join(userDataDir, 'orca-data.json'), + `${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n` + ) + + try { + serveApp = await electron.launch({ + args: [...getOrcaElectronLaunchArgs(mainPath, false), '--serve', '--serve-no-pairing'], + env + }) + const ownerPid = serveApp.process().pid + const client = new RuntimeClient(userDataDir, 5_000) + + await expect + .poll(async () => (await client.getCliStatus()).result.app.desktopWindowStatus, { + timeout: 60_000, + message: 'headless serve never became safely openable' + }) + .toBe('openable') + + const beforeStatus = await client.call('status.get') + const daemonPidBefore = readDaemonPid(userDataDir) + await client.call('repo.add', { path: repoPath, kind: 'git' }) + const created = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: `path:${repoPath}`, + title: 'Serve promotion continuity' + }) + const terminal = created.result.terminal + if (!terminal.ptyId) { + throw new Error('Headless terminal did not expose its daemon PTY id') + } + + const beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}` + await client.call('terminal.send', { + terminal: terminal.handle, + text: `echo ${beforeMarker}`, + enter: true + }) + await expect + .poll( + async () => { + const response = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: terminal.handle, + limit: 200 + }) + return response.result.terminal.tail.join('\n') + }, + { timeout: 15_000 } + ) + .toContain(beforeMarker) + + activatingProcess = spawn(electronPath, getOrcaElectronLaunchArgs(mainPath, false), { + env, + stdio: 'pipe' + }) + + const page = await serveApp.firstWindow({ timeout: 60_000 }) + await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + + const promotedPtyId = await discoverActivePtyId(page) + const afterStatus = await client.call('status.get') + expect(serveApp.process().pid).toBe(ownerPid) + expect(afterStatus.result.runtimeId).toBe(beforeStatus.result.runtimeId) + expect(afterStatus.result.desktopWindowStatus).toBe('available') + expect(promotedPtyId).toBe(terminal.ptyId) + expect(readDaemonPid(userDataDir)).toBe(daemonPidBefore) + expect(await waitForProcessExit(activatingProcess, 10_000)).toBe(true) + await waitForTerminalOutput(page, beforeMarker, 30_000) + + const afterMarker = `SERVE_PROMOTION_AFTER_${Date.now()}` + await execInTerminal(page, promotedPtyId, `echo ${afterMarker}`) + await waitForTerminalOutput(page, afterMarker, 15_000) + await expect(page.locator('.xterm:visible').first()).toBeVisible() + expect(await getTerminalContent(page)).toContain(beforeMarker) + } finally { + if (activatingProcess && activatingProcess.exitCode === null) { + activatingProcess.kill('SIGKILL') + await waitForProcessExit(activatingProcess, 5_000) + } + if (serveApp) { + await closeElectronAppForE2E(serveApp) + } + await cleanupE2EDaemons(userDataDir) + rmSync(userDataDir, { recursive: true, force: true }) + } +})