mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
# Conflicts: # config/reliability-gates.jsonc # src/main/runtime/orca-runtime.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-12",
|
||||
"updatedAt": "2026-07-13",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -140,6 +140,149 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -156,20 +299,24 @@
|
||||
"providers": ["local"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Focused tests and real-Monaco 9/50 MiB benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.",
|
||||
"coverageNotes": "Focused tests and real-Monaco 9/50 MiB performance and undo-retention benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"],
|
||||
"invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; arbitrary rewrites continue to replace the model content.",
|
||||
"oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, and full replacement for non-appends. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, forcing GC and event-loop settlement between every arm. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, renderer survival, and forced-GC heap/native-memory budgets.",
|
||||
"invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; read-only live tails do not create undo history, while editable external updates remain undoable and arbitrary rewrites continue to replace the model content.",
|
||||
"oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, full replacement for non-appends, and real-Monaco undo behavior for read-only live tails versus editable files. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, then compare exact Monaco undo-service and ArrayBuffer retention after five 10 MiB appends. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, non-undoability, renderer survival, and forced-GC heap/native-memory budgets.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
|
||||
"node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads",
|
||||
"node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads",
|
||||
"pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/editor/monaco-content-sync.test.ts",
|
||||
"src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
|
||||
"src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx",
|
||||
"src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx",
|
||||
"src/renderer/src/components/editor/monaco-content-sync.bench.ts",
|
||||
"src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts",
|
||||
"tests/e2e/agent-session-log-tail-stability.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
@@ -178,9 +325,16 @@
|
||||
"assertions": [
|
||||
"append-only drift reads the current model once and inserts only at the previous model end",
|
||||
"identical content emits no edit and non-append drift retains full replacement plus undo stops",
|
||||
"read-only live-tail appends, replacements, truncations, and stale retained-model remounts use non-undoing edits",
|
||||
"a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
|
||||
"assertions": [
|
||||
"a real Monaco read-only live-tail append leaves canUndo false while an ordinary external update remains undoable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx",
|
||||
"assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"]
|
||||
@@ -197,12 +351,19 @@
|
||||
"with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts",
|
||||
"assertions": [
|
||||
"five 10 MiB read-only live-tail appends retain zero Monaco undo-service and ArrayBuffer bytes while the undoable control retains at least 50 MiB"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/agent-session-log-tail-stability.spec.ts",
|
||||
"assertions": [
|
||||
"production Agent Session History opens a synthetic 9 MiB View Log and confirms the full model length loaded as font-metric-independent containment, with a generous content-height floor as a collapsed/truncated-render smoke check",
|
||||
"an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm",
|
||||
"three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content",
|
||||
"the production read-only live-tail model remains non-undoable before and after every watcher append",
|
||||
"the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls"
|
||||
]
|
||||
}
|
||||
@@ -234,6 +395,33 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 78,
|
||||
"summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4,
|
||||
"summary": "The real-Monaco undo-history test confirmed a read-only live-tail append leaves canUndo false while an ordinary external update remains undoable."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5,
|
||||
"summary": "The undoable 50 MiB control retained 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes; the read-only live-tail arm retained zero of both and remained non-undoable."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 78,
|
||||
"summary": "The production View Log journey preserved viewport, selection, Find state, renderer liveness, and forced-GC/native budgets across three watcher appends while canUndo remained false."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -246,11 +434,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The retained Electron gate itself executes the former read-only wrapper setValue behavior behind MODE=e2e at fixed 900x720, 13px, 1x zoom, the full 9 MiB model length loaded (with a generous content-height floor, since word-wrap pixel geometry is runner-dependent), and five-second cadence. The control retains a flat incoming value like the former controlled IPC prop. Each legacy arm must disrupt selection, Find active-match state, visible range, or scroll anchor, then restore identical model length/tail/geometry and anchor state before the fixed watcher arm; every fixed arm must preserve them. Production builds never install the control. The original exact-file dev repro also recorded renderer exit code 5."
|
||||
"evidence": "A fail-first real-Monaco test observed canUndo=true after one read-only live-tail append, and the forced-GC 50 MiB control retained 104,858,630 bytes in Monaco's undo service. After the fix the read-only arm retained zero undo-service bytes while the editable control stayed undoable. The retained Electron gate also proves each fixed watcher arm preserves viewport, selection, Find state, and non-undoability. Production builds never install its legacy setValue control."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node command requires --expose-gc and --pool=threads so worker GC is available; alternating fresh real-Monaco arms force GC and deterministic event-loop settlement after every operation. Observed p95 ranges: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms. Electron alternates each legacy replacement control and fixed watcher suffix from equivalent restored model state/geometry, samples forced-GC JS heap plus app.getAppMetrics renderer working set and OS private memory before/after/settled, asserts retained memory within max(20MiB,10%), and requires suffix peak deltas for jsHeapMb, workingSetMb, and privateMb not exceed the paired legacy controls."
|
||||
"evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node commands require --expose-gc and --pool=threads so worker GC is available. Current p95: 9 MiB append 5.93-7.12ms versus replacement 114.09-145.89ms; 50 MiB append 26.84-34.91ms versus replacement 602.36-699.21ms. The new 50 MiB retention arm measured 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes for the undoable control versus zero for read-only live-tail sync. Electron forced-GC JS-heap, renderer working-set, and OS-private-memory budgets also pass."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect stable soak history on macOS, Linux, and Windows.",
|
||||
|
||||
@@ -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'}`,
|
||||
|
||||
@@ -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<ReturnType<typeof createServer>>()
|
||||
const sockets = new Set<Socket>()
|
||||
|
||||
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,87 @@ 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<void>((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<void>((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'
|
||||
})
|
||||
// A blocked runtime can't promote, so we bail before spawning the app.
|
||||
expect(launchOrcaApp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times out if the runtime never responds', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus } from './status'
|
||||
import { getCliStatus, resolveDesktopWindowStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { sendWebSocketRequest } from './websocket-transport'
|
||||
@@ -115,7 +115,13 @@ export class RuntimeClient {
|
||||
// that this client machine has a local Orca desktop process.
|
||||
app: {
|
||||
running: false,
|
||||
pid: null
|
||||
pid: null,
|
||||
// Why: reuse the shared resolver so remote status honors the same
|
||||
// authoritativeWindowId fallback as local status for old runtimes.
|
||||
...(() => {
|
||||
const desktopWindowStatus = resolveDesktopWindowStatus(response.result)
|
||||
return desktopWindowStatus ? { desktopWindowStatus } : {}
|
||||
})()
|
||||
},
|
||||
runtime: {
|
||||
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
@@ -169,15 +175,27 @@ export class RuntimeClient {
|
||||
|
||||
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const initial = await this.getCliStatus()
|
||||
if (initial.result.runtime.reachable) {
|
||||
if (this.remotePairing) {
|
||||
return initial
|
||||
}
|
||||
|
||||
// Why: a blocked runtime can't open a window, so spawning the app would
|
||||
// only hit the single-instance lock and exit — bail before launching.
|
||||
if (initial.result.app.desktopWindowStatus === 'blocked') {
|
||||
throwDesktopActivationBlocked()
|
||||
}
|
||||
launchOrcaApp()
|
||||
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 +203,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,
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
export 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<CliStatusResult> {
|
||||
return {
|
||||
id: 'local-status',
|
||||
|
||||
@@ -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<TestRuntime> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
@@ -177,6 +206,7 @@ async function startTestRuntime(
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: statusOverrides.desktopWindowStatus,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion:
|
||||
|
||||
@@ -2,7 +2,11 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-sim-state-watcher'
|
||||
import {
|
||||
SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS,
|
||||
ServeSimStateWatcher,
|
||||
type ServeSimStateDetectedEvent
|
||||
} from './serve-sim-state-watcher'
|
||||
|
||||
const TEST_UDID = '11111111-2222-3333-4444-555555555555'
|
||||
|
||||
@@ -45,7 +49,9 @@ describe('ServeSimStateWatcher', () => {
|
||||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-'))
|
||||
cleanupPaths.push(parentDir)
|
||||
const stateDir = join(parentDir, 'serve-sim')
|
||||
const watcher = new ServeSimStateWatcher({ stateDir })
|
||||
// Force darwin (the poll is macOS-only) and a fast interval so this
|
||||
// cross-platform test exercises the existence-poll -> attach path quickly.
|
||||
const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 })
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
|
||||
watcher.bindPty('pty-1', 'worktree-1')
|
||||
@@ -78,6 +84,29 @@ describe('ServeSimStateWatcher', () => {
|
||||
watcher.stop()
|
||||
})
|
||||
|
||||
it('does not arm the existence poll on non-macOS platforms', () => {
|
||||
// serve-sim state never appears off macOS, so start() must not leave a
|
||||
// recurring timer waking the daemon for a directory that can never exist.
|
||||
for (const platform of ['win32', 'linux'] as const) {
|
||||
const watcher = new ServeSimStateWatcher({
|
||||
stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`),
|
||||
platform
|
||||
})
|
||||
watcher.start()
|
||||
const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll
|
||||
expect(poll).toBeNull()
|
||||
watcher.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults the existence poll to a coarse (battery-friendly) interval', () => {
|
||||
expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000)
|
||||
const watcher = new ServeSimStateWatcher()
|
||||
expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe(
|
||||
SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('does not buffer repeated brace-free PTY output while waiting for metadata', () => {
|
||||
const watcher = createIsolatedWatcher()
|
||||
const buffers = (watcher as unknown as { ptyBuffers: Map<string, string> }).ptyBuffers
|
||||
|
||||
@@ -23,6 +23,10 @@ export type ServeSimStateDetectedEvent = {
|
||||
}
|
||||
|
||||
const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim')
|
||||
// Why: this only waits for a rarely-created dir to appear; sub-second detection
|
||||
// of a detached emulator is not user-perceptible, so a coarse interval avoids
|
||||
// waking the daemon 4x/sec for the whole session on machines that never use it.
|
||||
export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000
|
||||
const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i
|
||||
const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g
|
||||
|
||||
@@ -73,6 +77,8 @@ function trailingIncompletePtyJsonObject(data: string): string {
|
||||
|
||||
export class ServeSimStateWatcher {
|
||||
private readonly stateDir: string
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly existencePollMs: number
|
||||
private readonly ptyToWorktree = new Map<string, string>()
|
||||
private readonly ptyBuffers = new Map<string, string>()
|
||||
private readonly seenExternalKeys = new Set<string>()
|
||||
@@ -81,8 +87,12 @@ export class ServeSimStateWatcher {
|
||||
private stateWatcher: FSWatcher | null = null
|
||||
private stateDirPoll: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(options: { stateDir?: string } = {}) {
|
||||
constructor(
|
||||
options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {}
|
||||
) {
|
||||
this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS
|
||||
}
|
||||
|
||||
onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void {
|
||||
@@ -171,6 +181,13 @@ export class ServeSimStateWatcher {
|
||||
if (this.stateDirPoll || this.stateWatcher) {
|
||||
return
|
||||
}
|
||||
// Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS,
|
||||
// so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the
|
||||
// existence poll there instead of waking the daemon every interval for a
|
||||
// directory that can never exist.
|
||||
if (this.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`.
|
||||
// Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR
|
||||
@@ -182,13 +199,16 @@ export class ServeSimStateWatcher {
|
||||
return
|
||||
}
|
||||
|
||||
// attachStateDirWatch() clears this poll once the dir appears and the
|
||||
// native watcher takes over, so it only ticks while waiting for a
|
||||
// rarely-created dir — a coarse interval keeps that wait off the idle floor.
|
||||
this.stateDirPoll = setInterval(() => {
|
||||
this.attachStateDirWatch()
|
||||
this.scanExistingStateFiles()
|
||||
}, 250)
|
||||
}, this.existencePollMs)
|
||||
this.stateDirPoll.unref?.()
|
||||
} catch {
|
||||
// Non-mac or permission issues: watcher is best-effort.
|
||||
// Permission issues: watcher is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-56
@@ -92,7 +92,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'
|
||||
@@ -103,6 +104,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'
|
||||
@@ -183,6 +185,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'
|
||||
@@ -244,6 +251,16 @@ let gpuFallbackActiveThisLaunch = false
|
||||
let localPtyStartupReady: Promise<void> = 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 <unpacked CLI entry>) 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,
|
||||
@@ -461,6 +478,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).
|
||||
@@ -551,22 +585,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) {
|
||||
@@ -633,12 +670,11 @@ ipcMain.handle(
|
||||
}
|
||||
)
|
||||
|
||||
function startDesktopFirstWindowStartupServices(): Promise<void> {
|
||||
function startTerminalRuntimeStartupServices(): Promise<void> {
|
||||
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)
|
||||
@@ -647,6 +683,9 @@ function startDesktopFirstWindowStartupServices(): Promise<void> {
|
||||
// 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',
|
||||
@@ -687,23 +726,6 @@ function startDesktopFirstWindowStartupServices(): Promise<void> {
|
||||
return firstWindowStartupServicesReady
|
||||
}
|
||||
|
||||
async function startServeAgentHookServer(): Promise<void> {
|
||||
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 =
|
||||
@@ -1791,20 +1813,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(),
|
||||
@@ -2095,9 +2111,8 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc)
|
||||
|
||||
if (!isServeMode) {
|
||||
startDesktopFirstWindowStartupServices()
|
||||
}
|
||||
startTerminalRuntimeStartupServices()
|
||||
app.on('activate', requestDesktopActivation)
|
||||
|
||||
if (serveOptions) {
|
||||
// Why: give managed WSL launchers a brief chance to migrate before headless
|
||||
@@ -2107,7 +2122,9 @@ app.whenReady().then(async () => {
|
||||
logStartupMilestone('wsl-cli-barrier-resolved', {
|
||||
reconciliation: managedWslCliReconciliationStatus
|
||||
})
|
||||
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,
|
||||
@@ -2125,11 +2142,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
|
||||
@@ -2212,15 +2230,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', () => {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
getSshFilesystemProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({ stat: vi.fn() }))
|
||||
vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
|
||||
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock
|
||||
}))
|
||||
|
||||
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
|
||||
|
||||
describe('remote filesystem watcher cancellation', () => {
|
||||
const handlers: HandlerMap = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
handleMock.mockReset()
|
||||
getSshFilesystemProviderMock.mockReset()
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
handleMock.mockImplementation((channel, handler) => {
|
||||
handlers[channel] = handler
|
||||
})
|
||||
registerFilesystemWatcherHandlers()
|
||||
await closeAllWatchers()
|
||||
})
|
||||
|
||||
it('aborts pending SSH setup after the last same-root listener leaves and cleans late success', async () => {
|
||||
let installSignal: AbortSignal | undefined
|
||||
let resolveInstall: ((unwatch: () => void) => void) | undefined
|
||||
const lateUnwatch = vi.fn()
|
||||
const watchMock = vi.fn(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installSignal = options?.signal
|
||||
resolveInstall = resolve
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise<unknown>
|
||||
const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
try {
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
expect(installSignal?.aborted).toBe(false)
|
||||
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await Promise.resolve()
|
||||
expect(installSignal?.aborted).toBe(false)
|
||||
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
|
||||
await Promise.resolve()
|
||||
expect(installSignal?.aborted).toBe(true)
|
||||
} finally {
|
||||
resolveInstall?.(lateUnwatch)
|
||||
await Promise.all([first, second])
|
||||
}
|
||||
expect(lateUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts a fresh same-root generation when a listener arrives after physical abort', async () => {
|
||||
let firstSignal: AbortSignal | undefined
|
||||
let secondCallback: ((events: unknown[]) => void) | undefined
|
||||
const secondUnwatch = vi.fn()
|
||||
const watchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((_resolve, reject) => {
|
||||
firstSignal = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const error = new Error('cancelled')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce((_rootPath, callback) => {
|
||||
secondCallback = callback
|
||||
return Promise.resolve(secondUnwatch)
|
||||
})
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true))
|
||||
const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.all([first, second])
|
||||
expect(watchMock).toHaveBeenCalledTimes(2)
|
||||
secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
|
||||
expect(secondSender.send).toHaveBeenCalledTimes(1)
|
||||
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
|
||||
expect(secondUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('aborts pending SSH setup on sender destruction and watcher shutdown', async () => {
|
||||
const installs = new Map<
|
||||
string,
|
||||
{ signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void }
|
||||
>()
|
||||
const watchMock = vi.fn(
|
||||
(rootPath: string, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installs.set(rootPath, { signal: options?.signal, resolve })
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const destroyedCallbacks: (() => void)[] = []
|
||||
const destroyedSender = {
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn((event: string, callback: () => void) => {
|
||||
if (event === 'destroyed') {
|
||||
destroyedCallbacks.push(callback)
|
||||
}
|
||||
}),
|
||||
id: 1
|
||||
}
|
||||
const destroyedArgs = { worktreePath: '/destroyed', connectionId: 'conn-1' }
|
||||
const destroyedWatch = handlers['fs:watchWorktree'](
|
||||
{ sender: destroyedSender },
|
||||
destroyedArgs
|
||||
) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
destroyedCallbacks[0]()
|
||||
await Promise.resolve()
|
||||
expect(installs.get('/destroyed')?.signal?.aborted).toBe(true)
|
||||
installs.get('/destroyed')?.resolve(vi.fn())
|
||||
await destroyedWatch
|
||||
|
||||
const shutdownSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' }
|
||||
const shutdownWatch = handlers['fs:watchWorktree'](
|
||||
{ sender: shutdownSender },
|
||||
shutdownArgs
|
||||
) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
await closeAllWatchers()
|
||||
expect(installs.get('/shutdown')?.signal?.aborted).toBe(true)
|
||||
installs.get('/shutdown')?.resolve(vi.fn())
|
||||
await shutdownWatch
|
||||
})
|
||||
|
||||
it('keeps the shared install alive when a replacement sender joins before the deferred abort fires', async () => {
|
||||
let installSignal: AbortSignal | undefined
|
||||
let resolveInstall: ((unwatch: () => void) => void) | undefined
|
||||
const watchMock = vi.fn(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installSignal = options?.signal
|
||||
resolveInstall = resolve
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The last listener leaves and a replacement joins in the SAME tick — before
|
||||
// the queued abort microtask runs. The shared install must survive.
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(installSignal?.aborted).toBe(false)
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveInstall?.(vi.fn())
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
|
||||
it('refuses a post-shutdown joiner recursion instead of resurrecting the install', async () => {
|
||||
let firstSignal: AbortSignal | undefined
|
||||
let resolveFirst: ((unwatch: () => void) => void) | undefined
|
||||
const lateUnwatch = vi.fn()
|
||||
const watchMock = vi.fn(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
firstSignal = options?.signal
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Last listener leaves -> deferred abort fires while the install is still pending.
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true))
|
||||
|
||||
// Joiner arrives after physical abort (canJoinInstall === false); it awaits the
|
||||
// 'cancelled' resolution and would recurse into a fresh install.
|
||||
const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise<unknown>
|
||||
await Promise.resolve()
|
||||
|
||||
// Shutdown latches the subsystem before the joiner's recursion runs.
|
||||
await closeAllWatchers()
|
||||
|
||||
// Late success of the aborted generation must be unwatched, not registered.
|
||||
resolveFirst?.(lateUnwatch)
|
||||
await Promise.all([first, second])
|
||||
|
||||
// The recursion is refused post-shutdown: provider.watch() is never called again.
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
expect(lateUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a pre-shutdown joiner recursion even after a new watch reopens the subsystem', async () => {
|
||||
const installs = new Map<
|
||||
string,
|
||||
{ signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void }
|
||||
>()
|
||||
const watchMock = vi.fn(
|
||||
(rootPath: string, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installs.set(rootPath, { signal: options?.signal, resolve })
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' }
|
||||
const lateUnwatch = vi.fn()
|
||||
const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Last listener leaves -> deferred abort fires while the install is still pending.
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await vi.waitFor(() => expect(installs.get('/home/me/repo')?.signal?.aborted).toBe(true))
|
||||
|
||||
// Joiner arrives after physical abort (canJoinInstall === false); it captures the
|
||||
// current lifecycle generation and awaits the 'cancelled' resolution.
|
||||
const joiner = handlers['fs:watchWorktree']({ sender: joinerSender }, args) as Promise<unknown>
|
||||
await Promise.resolve()
|
||||
|
||||
// Shutdown bumps the generation, then a genuine new watch reopens the subsystem
|
||||
// (clearing the boolean latch) before the joiner resumes.
|
||||
await closeAllWatchers()
|
||||
const reopen = handlers['fs:watchWorktree'](
|
||||
{ sender: reopenSender },
|
||||
reopenArgs
|
||||
) as Promise<unknown>
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Now let the aborted install resolve; the joiner recurses on the stale generation.
|
||||
installs.get('/home/me/repo')?.resolve(lateUnwatch)
|
||||
installs.get('/home/me/other')?.resolve(vi.fn())
|
||||
await Promise.all([first, joiner, reopen])
|
||||
|
||||
// The joiner's recursion is refused despite the reopen: no third provider.watch().
|
||||
expect(watchMock).toHaveBeenCalledTimes(2)
|
||||
expect(watchMock.mock.calls.filter(([rootPath]) => rootPath === '/home/me/repo')).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(lateUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -124,7 +124,9 @@ describe('registerFilesystemWatcherHandlers', () => {
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function))
|
||||
expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
const onEvents = watchMock.mock.calls[0][1]
|
||||
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
|
||||
expect(sendMock).toHaveBeenCalledWith('fs:changed', {
|
||||
@@ -161,7 +163,9 @@ describe('registerFilesystemWatcherHandlers', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function))
|
||||
expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
handlers['fs:unwatchWorktree'](
|
||||
{ sender: { id: 1 } },
|
||||
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
|
||||
@@ -652,22 +652,31 @@ type RemoteWatcherState = {
|
||||
type RemoteWatcherInstallToken = {
|
||||
cancelled: boolean
|
||||
listeners: Map<number, WebContents>
|
||||
abortController: AbortController
|
||||
abortScheduled: boolean
|
||||
}
|
||||
|
||||
// Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state.
|
||||
const remoteWatchers = new Map<string, RemoteWatcherState>()
|
||||
const loggedUnavailableRemoteWatchers = new Set<string>()
|
||||
const pendingRemoteWatcherRetries = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// Why: track in-flight `provider.watch()` calls so an unwatch/shutdown that
|
||||
// arrives while a watch is still resolving can mark the install cancelled.
|
||||
// Without this, the awaited unwatch handle would be installed after the
|
||||
// renderer thinks the watch is gone, leaking a native watcher.
|
||||
// Why: track in-flight `provider.watch()` calls so last-listener cleanup can
|
||||
// abort relay setup, while late success is still unwatched instead of leaked.
|
||||
const inFlightRemoteInstalls = new Map<string, RemoteWatcherInstallToken>()
|
||||
// Why: dedupe concurrent installRemoteWatcher calls for the same key so
|
||||
// overlapping fs:watchWorktree IPCs share one native watcher and one listener
|
||||
// map, instead of each call independently invoking provider.watch() and
|
||||
// overwriting the per-key state on resolution.
|
||||
const pendingRemoteInstallPromises = new Map<string, Promise<RemoteWatcherInstallResult>>()
|
||||
// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner
|
||||
// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing
|
||||
// the token-abort loop. A genuine new fs:watchWorktree clears the latch.
|
||||
let remoteWatchersClosed = false
|
||||
// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a
|
||||
// fresh call once a genuine new watch reopens the subsystem. Each call captures
|
||||
// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited
|
||||
// across a shutdown+reopen recurses on a stale generation and is refused.
|
||||
let remoteWatcherLifecycleGeneration = 0
|
||||
const REMOTE_WATCH_RETRY_MS = 1_000
|
||||
const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000
|
||||
|
||||
@@ -675,7 +684,7 @@ function addInFlightRemoteInstallListener(
|
||||
token: RemoteWatcherInstallToken,
|
||||
sender: WebContents
|
||||
): void {
|
||||
if (sender.isDestroyed()) {
|
||||
if (sender.isDestroyed() || token.abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
token.listeners.set(sender.id, sender)
|
||||
@@ -683,12 +692,26 @@ function addInFlightRemoteInstallListener(
|
||||
registerSenderCleanup(sender)
|
||||
}
|
||||
|
||||
function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken): void {
|
||||
token.cancelled = token.listeners.size === 0
|
||||
if (!token.cancelled || token.abortScheduled || token.abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
token.abortScheduled = true
|
||||
// Why: a replacement sender can synchronously revive the shared install
|
||||
// during a renderer handoff; otherwise stop the relay crawl next microtask.
|
||||
queueMicrotask(() => {
|
||||
token.abortScheduled = false
|
||||
if (token.cancelled && token.listeners.size === 0) {
|
||||
token.abortController.abort()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupInFlightRemoteInstallsForSender(senderId: number): void {
|
||||
for (const token of inFlightRemoteInstalls.values()) {
|
||||
token.listeners.delete(senderId)
|
||||
if (token.listeners.size === 0) {
|
||||
token.cancelled = true
|
||||
}
|
||||
cancelInFlightRemoteInstallIfUnowned(token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,8 +749,16 @@ type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
|
||||
async function installRemoteWatcher(
|
||||
sender: WebContents,
|
||||
connectionId: string,
|
||||
worktreePath: string
|
||||
worktreePath: string,
|
||||
generation = remoteWatcherLifecycleGeneration
|
||||
): Promise<RemoteWatcherInstallResult> {
|
||||
// Why: refuse installs racing in after teardown (joiner recursion, fired retry
|
||||
// tick) so provider.watch() is never called and registered post-shutdown. The
|
||||
// generation guard also refuses a waiter that captured an earlier lifecycle,
|
||||
// even after a new watch reopened the subsystem.
|
||||
if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) {
|
||||
return 'cancelled'
|
||||
}
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider || sender.isDestroyed()) {
|
||||
return 'unavailable'
|
||||
@@ -747,7 +778,8 @@ async function installRemoteWatcher(
|
||||
const pendingInstall = pendingRemoteInstallPromises.get(key)
|
||||
if (pendingInstall) {
|
||||
const inFlight = inFlightRemoteInstalls.get(key)
|
||||
if (inFlight) {
|
||||
const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted
|
||||
if (canJoinInstall) {
|
||||
// Why: a new watcher can join after all previous pending listeners
|
||||
// unwatched but before provider.watch() resolves; revive that install
|
||||
// instead of inheriting the stale cancellation.
|
||||
@@ -762,9 +794,27 @@ async function installRemoteWatcher(
|
||||
) {
|
||||
addRemoteWatchListener(key, sender)
|
||||
}
|
||||
if (
|
||||
result === 'cancelled' &&
|
||||
!canJoinInstall &&
|
||||
!sender.isDestroyed() &&
|
||||
generation === remoteWatcherLifecycleGeneration
|
||||
) {
|
||||
// Why: AbortSignal cannot be revived. A listener arriving after physical
|
||||
// cancellation waits out that generation, then owns a fresh install.
|
||||
if (pendingRemoteInstallPromises.get(key) === pendingInstall) {
|
||||
pendingRemoteInstallPromises.delete(key)
|
||||
}
|
||||
return installRemoteWatcher(sender, connectionId, worktreePath, generation)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const cancelToken: RemoteWatcherInstallToken = { cancelled: false, listeners: new Map() }
|
||||
const cancelToken: RemoteWatcherInstallToken = {
|
||||
cancelled: false,
|
||||
listeners: new Map(),
|
||||
abortController: new AbortController(),
|
||||
abortScheduled: false
|
||||
}
|
||||
inFlightRemoteInstalls.set(key, cancelToken)
|
||||
addInFlightRemoteInstallListener(cancelToken, sender)
|
||||
const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken)
|
||||
@@ -786,22 +836,29 @@ async function doInstallRemoteWatcher(
|
||||
): Promise<RemoteWatcherInstallResult> {
|
||||
let unwatch: () => void
|
||||
try {
|
||||
unwatch = await provider.watch(worktreePath, (events) => {
|
||||
const state = remoteWatchers.get(key)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
for (const listener of state.listeners.values()) {
|
||||
if (listener.isDestroyed()) {
|
||||
continue
|
||||
unwatch = await provider.watch(
|
||||
worktreePath,
|
||||
(events) => {
|
||||
const state = remoteWatchers.get(key)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
listener.send('fs:changed', {
|
||||
worktreePath,
|
||||
events
|
||||
} satisfies FsChangedPayload)
|
||||
}
|
||||
})
|
||||
for (const listener of state.listeners.values()) {
|
||||
if (listener.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
listener.send('fs:changed', {
|
||||
worktreePath,
|
||||
events
|
||||
} satisfies FsChangedPayload)
|
||||
}
|
||||
},
|
||||
{ signal: cancelToken.abortController.signal }
|
||||
)
|
||||
} catch (err) {
|
||||
if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) {
|
||||
return 'cancelled'
|
||||
}
|
||||
console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err)
|
||||
return 'unavailable'
|
||||
} finally {
|
||||
@@ -884,6 +941,9 @@ export function registerFilesystemWatcherHandlers(): void {
|
||||
'fs:watchWorktree',
|
||||
async (event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
// Why: a real new watch reopens the subsystem after closeAllWatchers
|
||||
// latched it shut (also how tests reset between cases).
|
||||
remoteWatchersClosed = false
|
||||
const key = `${args.connectionId}:${args.worktreePath}`
|
||||
const result = await installRemoteWatcher(
|
||||
event.sender,
|
||||
@@ -923,7 +983,7 @@ export function registerFilesystemWatcherHandlers(): void {
|
||||
const inFlight = inFlightRemoteInstalls.get(key)
|
||||
if (inFlight) {
|
||||
inFlight.listeners.delete(_event.sender.id)
|
||||
inFlight.cancelled = inFlight.listeners.size === 0
|
||||
cancelInFlightRemoteInstallIfUnowned(inFlight)
|
||||
}
|
||||
loggedUnavailableRemoteWatchers.delete(key)
|
||||
releaseRemoteWatchListener(key, _event?.sender?.id ?? 0)
|
||||
@@ -951,10 +1011,19 @@ export async function closeAllWatchers(): Promise<void> {
|
||||
}
|
||||
pendingRemoteWatcherRetries.clear()
|
||||
loggedUnavailableRemoteWatchers.clear()
|
||||
// Why: latch the subsystem shut and drop the dedup map so a late install that
|
||||
// begins after teardown is refused instead of registering post-shutdown. Bump
|
||||
// the generation so a waiter that resumes after a later reopen still recurses
|
||||
// on a stale lifecycle and is refused.
|
||||
remoteWatchersClosed = true
|
||||
remoteWatcherLifecycleGeneration += 1
|
||||
pendingRemoteInstallPromises.clear()
|
||||
// Why: cancel any in-flight provider.watch() calls so their resolved
|
||||
// unwatch handles are discarded instead of being installed after shutdown.
|
||||
for (const token of inFlightRemoteInstalls.values()) {
|
||||
token.listeners.clear()
|
||||
token.cancelled = true
|
||||
token.abortController.abort()
|
||||
}
|
||||
for (const token of inFlightLocalInstalls.values()) {
|
||||
token.listeners.clear()
|
||||
|
||||
@@ -162,6 +162,30 @@ describe('RuntimeWatcherProcessPool', () => {
|
||||
expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow'])
|
||||
})
|
||||
|
||||
it('allows only one quarantine generation when isolated setup also times out', async () => {
|
||||
const timeout = new WatcherProcessFailure(
|
||||
'file watcher subscription timed out',
|
||||
'subscription',
|
||||
'subscribe_timeout'
|
||||
)
|
||||
pool = new RuntimeWatcherProcessPool({
|
||||
maxSharedSupervisors: 1,
|
||||
createSupervisor: () => {
|
||||
const supervisor = new FakeSupervisor()
|
||||
supervisor.subscribeError = timeout
|
||||
supervisors.push(supervisor)
|
||||
return supervisor
|
||||
}
|
||||
})
|
||||
|
||||
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout)
|
||||
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout)
|
||||
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toMatchObject({
|
||||
code: 'supervisor_crash_fuse'
|
||||
})
|
||||
expect(supervisors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('moves a live root into quarantine when crash resubscription times out', async () => {
|
||||
const timeout = new WatcherProcessFailure(
|
||||
'file watcher resubscription timed out',
|
||||
@@ -179,6 +203,37 @@ describe('RuntimeWatcherProcessPool', () => {
|
||||
expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery'])
|
||||
})
|
||||
|
||||
it('does not replace an isolated live root after its resubscription times out', async () => {
|
||||
pool = new RuntimeWatcherProcessPool({
|
||||
maxSharedSupervisors: 1,
|
||||
createSupervisor: () => {
|
||||
const supervisor = new FakeSupervisor()
|
||||
supervisors.push(supervisor)
|
||||
return supervisor
|
||||
}
|
||||
})
|
||||
const fused = new WatcherProcessFailure(
|
||||
'file watcher process crashed repeatedly',
|
||||
'supervisor',
|
||||
'supervisor_crash_fuse'
|
||||
)
|
||||
const timeout = new WatcherProcessFailure(
|
||||
'file watcher resubscription timed out',
|
||||
'subscription',
|
||||
'subscribe_timeout'
|
||||
)
|
||||
await pool.subscribe('/slow-recovery', vi.fn(), {}, {})
|
||||
supervisors[0].subscriptions[0].hooks.onTerminalError?.(fused)
|
||||
await pool.subscribe('/slow-recovery', vi.fn(), {}, {})
|
||||
|
||||
supervisors[1].subscriptions[0].hooks.onTerminalError?.(timeout)
|
||||
|
||||
await expect(pool.subscribe('/slow-recovery', vi.fn(), {}, {})).rejects.toMatchObject({
|
||||
code: 'supervisor_crash_fuse'
|
||||
})
|
||||
expect(supervisors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps healthy shard assignments after a root-specific failure', async () => {
|
||||
pool = new RuntimeWatcherProcessPool({
|
||||
maxSharedSupervisors: 1,
|
||||
|
||||
@@ -77,10 +77,7 @@ export class RuntimeWatcherProcessPool {
|
||||
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
|
||||
this.retireSlot(slot)
|
||||
} else {
|
||||
releaseAssignment()
|
||||
if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
|
||||
this.isolatedRoots.add(dir)
|
||||
}
|
||||
this.releaseFailedRoot(assignment, dir, error, releaseAssignment)
|
||||
}
|
||||
hooks.onTerminalError?.(error)
|
||||
}
|
||||
@@ -94,10 +91,7 @@ export class RuntimeWatcherProcessPool {
|
||||
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
|
||||
this.retireSlot(slot)
|
||||
} else {
|
||||
releaseAssignment()
|
||||
if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
|
||||
this.isolatedRoots.add(dir)
|
||||
}
|
||||
this.releaseFailedRoot(assignment, dir, error, releaseAssignment)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -207,14 +201,7 @@ export class RuntimeWatcherProcessPool {
|
||||
if (this.assignments.get(root)?.slot === slot) {
|
||||
this.assignments.delete(root)
|
||||
}
|
||||
if (slot.isolated) {
|
||||
// Why: one bounded quarantine attempt is the recovery budget for a
|
||||
// watch lifetime; repeated fused replacements would recreate churn.
|
||||
this.isolatedRoots.delete(root)
|
||||
this.failedQuarantineRoots.add(root)
|
||||
} else {
|
||||
this.isolatedRoots.add(root)
|
||||
}
|
||||
this.quarantineOrFuse(root, slot.isolated)
|
||||
}
|
||||
slot.roots.clear()
|
||||
// Why: failAllSubscriptions is still iterating callbacks; defer disposal
|
||||
@@ -240,6 +227,31 @@ export class RuntimeWatcherProcessPool {
|
||||
}
|
||||
}
|
||||
|
||||
private releaseFailedRoot(
|
||||
assignment: RuntimeWatcherPoolAssignment,
|
||||
dir: string,
|
||||
error: unknown,
|
||||
releaseAssignment: () => void
|
||||
): void {
|
||||
releaseAssignment()
|
||||
if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') {
|
||||
return
|
||||
}
|
||||
this.quarantineOrFuse(dir, assignment.slot.isolated)
|
||||
}
|
||||
|
||||
// Why: one bounded quarantine attempt is the recovery budget per watch
|
||||
// lifetime; an already-isolated root that fails again is fused, not re-isolated,
|
||||
// so it cannot spawn another child generation.
|
||||
private quarantineOrFuse(dir: string, isolated: boolean): void {
|
||||
if (isolated) {
|
||||
this.isolatedRoots.delete(dir)
|
||||
this.failedQuarantineRoots.add(dir)
|
||||
return
|
||||
}
|
||||
this.isolatedRoots.add(dir)
|
||||
}
|
||||
|
||||
private disposeSlot(slot: RuntimeWatcherPoolSlot): void {
|
||||
if (slot.disposed) {
|
||||
return
|
||||
|
||||
@@ -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,
|
||||
@@ -1454,6 +1457,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: 'openable',
|
||||
rendererGraphEpoch: 0
|
||||
})
|
||||
expect(runtime.getRuntimeId()).toBeTruthy()
|
||||
@@ -1548,6 +1552,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()
|
||||
|
||||
@@ -6875,6 +6991,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<string, { commandCodeDetector: unknown }>
|
||||
}
|
||||
).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)
|
||||
@@ -9932,11 +10076,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)
|
||||
|
||||
@@ -174,6 +174,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,
|
||||
@@ -2555,8 +2559,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<string, string>) | null
|
||||
private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus
|
||||
private accountServices: RuntimeAccountServices | null = null
|
||||
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
|
||||
private automationService: AutomationService | null = null
|
||||
@@ -2590,6 +2596,7 @@ export class OrcaRuntimeService {
|
||||
// managed-Codex sessions. The runtime ctor runs in BOTH window and serve.
|
||||
getAdditionalAiVaultCodexHomePaths?: () => readonly string[]
|
||||
buildAgentHookPtyEnv?: () => Record<string, string>
|
||||
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
|
||||
}
|
||||
) {
|
||||
this.store = store
|
||||
@@ -2616,6 +2623,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
|
||||
@@ -3018,6 +3026,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,
|
||||
@@ -3132,11 +3141,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
|
||||
@@ -3261,6 +3337,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)
|
||||
@@ -6048,7 +6125,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)
|
||||
@@ -6064,7 +6141,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 = {
|
||||
@@ -6243,7 +6324,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' })
|
||||
@@ -6276,7 +6357,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) => {
|
||||
@@ -6375,6 +6456,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
|
||||
@@ -18032,12 +18126,12 @@ export class OrcaRuntimeService {
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
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) ||
|
||||
@@ -18174,7 +18268,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.
|
||||
// Re-check after preparation in case the window closed meanwhile.
|
||||
...(launchOpts.persistHostSessionBinding ||
|
||||
this.getAvailableAuthoritativeWindow() === null
|
||||
? { persistHostSessionBinding: true }
|
||||
: {})
|
||||
})
|
||||
this.registerPreAllocatedHandleForPty(spawned.id, preAllocatedHandle)
|
||||
this.registerPty(spawned.id, workspace.id, workspace.connectionId)
|
||||
@@ -19673,6 +19772,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()
|
||||
@@ -19689,6 +19789,7 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
this.graphStatus = 'ready'
|
||||
this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID)
|
||||
this.refreshWritableFlags()
|
||||
}
|
||||
|
||||
@@ -19702,6 +19803,7 @@ export class OrcaRuntimeService {
|
||||
this.rendererGraphEpoch += 1
|
||||
}
|
||||
this.graphStatus = 'unavailable'
|
||||
this.setTerminalSideEffectConsumerAvailable(false)
|
||||
this.authoritativeWindowId = null
|
||||
this.rememberDetachedPreAllocatedLeaves()
|
||||
this.tabs.clear()
|
||||
|
||||
@@ -305,6 +305,25 @@ describe('SshChannelMultiplexer', () => {
|
||||
expect(() => vi.advanceTimersByTime(5_000)).not.toThrow()
|
||||
expect(mux.isDisposed()).toBe(true)
|
||||
})
|
||||
|
||||
it('drives keepalive sends AND dead-link detection from a single interval', () => {
|
||||
// The keepalive send and the liveness/timeout check were merged from two
|
||||
// 5s intervals into one; there must be exactly one recurring timer.
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// Send half: a keepalive is written on the tick.
|
||||
const before = transport.written.length
|
||||
vi.advanceTimersByTime(5_000)
|
||||
expect(transport.written.length).toBeGreaterThan(before)
|
||||
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// Check half: with no inbound frames or acks, the same interval declares
|
||||
// the link dead (no-data + oldest-unacked both exceed the 20s window).
|
||||
expect(mux.isDisposed()).toBe(false)
|
||||
vi.advanceTimersByTime(25_000)
|
||||
expect(mux.isDisposed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wake guard (timer pause across system sleep, #7773)', () => {
|
||||
|
||||
@@ -54,8 +54,7 @@ export class SshChannelMultiplexer {
|
||||
// generic notification listener that already serves fs.changed.
|
||||
private methodNotificationHandlers = new Map<string, Set<MethodNotificationHandler>>()
|
||||
private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = []
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null
|
||||
private timeoutTimer: ReturnType<typeof setInterval> | null = null
|
||||
private connectionHealthTimer: ReturnType<typeof setInterval> | null = null
|
||||
private disposed = false
|
||||
|
||||
// Track the oldest unacked outgoing message timestamp
|
||||
@@ -88,8 +87,7 @@ export class SshChannelMultiplexer {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.startKeepalive()
|
||||
this.startTimeoutCheck()
|
||||
this.startConnectionHealthTimer()
|
||||
}
|
||||
|
||||
onNotification(handler: NotificationHandler): () => void {
|
||||
@@ -276,13 +274,9 @@ export class SshChannelMultiplexer {
|
||||
}
|
||||
this.disposed = true
|
||||
|
||||
if (this.keepaliveTimer) {
|
||||
clearInterval(this.keepaliveTimer)
|
||||
this.keepaliveTimer = null
|
||||
}
|
||||
if (this.timeoutTimer) {
|
||||
clearInterval(this.timeoutTimer)
|
||||
this.timeoutTimer = null
|
||||
if (this.connectionHealthTimer) {
|
||||
clearInterval(this.connectionHealthTimer)
|
||||
this.connectionHealthTimer = null
|
||||
}
|
||||
|
||||
// Why: the renderer uses the error code to distinguish temporary disconnects
|
||||
@@ -488,15 +482,17 @@ export class SshChannelMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
private startKeepalive(): void {
|
||||
this.keepaliveTimer = setInterval(() => {
|
||||
this.sendKeepAlive()
|
||||
}, KEEPALIVE_SEND_MS)
|
||||
}
|
||||
|
||||
private startTimeoutCheck(): void {
|
||||
// Why: one 5s interval owns BOTH the periodic keepalive send and the
|
||||
// liveness/dead-link check. They used to be two separate 5s intervals that
|
||||
// always fired back-to-back (keepalive created first); folding them into one
|
||||
// tick — send first (as the keepalive timer did), then run the check (as the
|
||||
// timeout timer did) — halves the per-connection timer count with identical
|
||||
// behavior, including the #7773 wake-gap recovery below.
|
||||
private startConnectionHealthTimer(): void {
|
||||
let lastTickAt = Date.now()
|
||||
this.timeoutTimer = setInterval(() => {
|
||||
this.connectionHealthTimer = setInterval(() => {
|
||||
this.sendKeepAlive()
|
||||
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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'}`,
|
||||
|
||||
@@ -232,6 +232,7 @@ export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliI
|
||||
].join('\n')
|
||||
}
|
||||
],
|
||||
postWriteCommands: [`chmod +x ${quoteSh(launcherPath)} 2>/dev/null; true`]
|
||||
// Surface chmod failures: a non-executable launcher must fail install loudly, not silently.
|
||||
postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,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,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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(serveIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(sentinelIndex).toBeGreaterThan(serveIndex)
|
||||
expect(rpcIndex).toBeGreaterThan(sentinelIndex)
|
||||
expect(settleIndex).toBeGreaterThan(rpcIndex)
|
||||
expect(source).not.toContain('runtime.syncWindowGraph(0,')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types'
|
||||
|
||||
type ActivationGateState = Exclude<RuntimeDesktopWindowStatus, 'available' | 'openable'> | '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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner'
|
||||
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import type { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -1056,8 +1057,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}, [fetchAllWorktrees, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setRelativeNow(Date.now()), 60 * 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
// Pause the relative-time clock while the window is hidden.
|
||||
return installWindowVisibilityInterval({
|
||||
run: () => setRelativeNow(Date.now()),
|
||||
intervalMs: 60 * 1000
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
const lifecycle = vi.hoisted(() => ({
|
||||
events: [] as string[],
|
||||
models: new Map<string, { content: string; undo: string[] }>()
|
||||
models: new Map<string, { content: string; undo: string[] }>(),
|
||||
mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[]
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/lazy-with-retry', async () => {
|
||||
@@ -16,10 +17,20 @@ vi.mock('@/lib/lazy-with-retry', async () => {
|
||||
if (!factory.toString().includes('/MonacoEditor.tsx')) {
|
||||
return () => null
|
||||
}
|
||||
return function MockRetainedMonaco(props: { filePath: string; content: string }) {
|
||||
return function MockRetainedMonaco(props: {
|
||||
filePath: string
|
||||
content: string
|
||||
readOnly?: boolean
|
||||
liveTail?: boolean
|
||||
}) {
|
||||
/* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */
|
||||
React.useEffect(() => {
|
||||
lifecycle.events.push(`mount:${props.filePath}`)
|
||||
lifecycle.mountedProps.push({
|
||||
filePath: props.filePath,
|
||||
readOnly: props.readOnly,
|
||||
liveTail: props.liveTail
|
||||
})
|
||||
const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] }
|
||||
const model = {
|
||||
getValue: () => retained.content,
|
||||
@@ -93,7 +104,7 @@ vi.mock('@/store', () => ({
|
||||
|
||||
import { EditorContent } from './EditorContent'
|
||||
|
||||
function file(filePath: string): OpenFile {
|
||||
function file(filePath: string, overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: filePath,
|
||||
filePath,
|
||||
@@ -101,7 +112,8 @@ function file(filePath: string): OpenFile {
|
||||
worktreeId: 'repo::/repo',
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
mode: 'edit',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +148,7 @@ afterEach(() => {
|
||||
cleanup()
|
||||
lifecycle.events.length = 0
|
||||
lifecycle.models.clear()
|
||||
lifecycle.mountedProps.length = 0
|
||||
})
|
||||
|
||||
describe('EditorContent Monaco lifecycle boundary', () => {
|
||||
@@ -162,4 +175,14 @@ describe('EditorContent Monaco lifecycle boundary', () => {
|
||||
undo: ['first undo']
|
||||
})
|
||||
})
|
||||
|
||||
it('passes live-tail ownership only for a read-only live log', () => {
|
||||
const liveLog = file('/repo/session.jsonl', { readOnly: true, liveTail: true })
|
||||
|
||||
render(<EditorContent {...props(liveLog, 'session content')} />)
|
||||
|
||||
expect(lifecycle.mountedProps).toEqual([
|
||||
{ filePath: liveLog.filePath, readOnly: true, liveTail: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -327,6 +327,7 @@ export function EditorContent({
|
||||
// the change/save callbacks so no draft, dirty state, or write can occur —
|
||||
// mirrors the conflict-review read-only rendering pattern.
|
||||
readOnly={activeFile.readOnly === true}
|
||||
liveTail={activeFile.liveTail === true}
|
||||
onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange}
|
||||
onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave}
|
||||
worktreeId={activeFile.worktreeId}
|
||||
|
||||
@@ -15,7 +15,11 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select
|
||||
|
||||
import { useContextualCopySetup } from './useContextualCopySetup'
|
||||
import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal'
|
||||
import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync'
|
||||
import {
|
||||
syncContentOnMount,
|
||||
syncContentUpdate,
|
||||
type MonacoContentSyncMode
|
||||
} from './monaco-content-sync'
|
||||
import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search'
|
||||
import {
|
||||
beginProgrammaticContentSync,
|
||||
@@ -77,6 +81,7 @@ type MonacoEditorProps = {
|
||||
markdownAnnotationsEnabled?: boolean
|
||||
conflictDecorationsEnabled?: boolean
|
||||
readOnly?: boolean
|
||||
liveTail?: boolean
|
||||
autoHeight?: boolean
|
||||
}
|
||||
|
||||
@@ -101,6 +106,7 @@ export default function MonacoEditor({
|
||||
markdownAnnotationsEnabled = false,
|
||||
conflictDecorationsEnabled = false,
|
||||
readOnly = false,
|
||||
liveTail = false,
|
||||
autoHeight = false
|
||||
}: MonacoEditorProps): React.JSX.Element {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
|
||||
@@ -131,6 +137,8 @@ export default function MonacoEditor({
|
||||
propsRef.current = { relativePath, language, onSave, onContentChange }
|
||||
const readOnlyRef = useRef(readOnly)
|
||||
readOnlyRef.current = readOnly
|
||||
const contentSyncModeRef = useRef<MonacoContentSyncMode>('undoable')
|
||||
contentSyncModeRef.current = readOnly && liveTail ? 'read-only-live-tail' : 'undoable'
|
||||
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
@@ -370,7 +378,11 @@ export default function MonacoEditor({
|
||||
beginProgrammaticContentSync(filePath)
|
||||
isApplyingProgrammaticContentRef.current = true
|
||||
try {
|
||||
const didSyncOnMount = syncContentOnMount(editorInstance, contentRef.current)
|
||||
const didSyncOnMount = syncContentOnMount(
|
||||
editorInstance,
|
||||
contentRef.current,
|
||||
contentSyncModeRef.current
|
||||
)
|
||||
if (didSyncOnMount) {
|
||||
lastSyncedContentRef.current = contentRef.current
|
||||
}
|
||||
@@ -672,7 +684,7 @@ export default function MonacoEditor({
|
||||
beginProgrammaticContentSync(filePath)
|
||||
isApplyingProgrammaticContentRef.current = true
|
||||
try {
|
||||
syncContentUpdate(ed, content)
|
||||
syncContentUpdate(ed, content, contentSyncModeRef.current)
|
||||
lastSyncedContentRef.current = content
|
||||
} finally {
|
||||
isApplyingProgrammaticContentRef.current = false
|
||||
|
||||
@@ -9,6 +9,7 @@ function createHarness(
|
||||
editorInstance: editor.IStandaloneCodeEditor
|
||||
getValue: ReturnType<typeof vi.fn>
|
||||
getFullModelRange: ReturnType<typeof vi.fn>
|
||||
applyEdits: ReturnType<typeof vi.fn>
|
||||
pushEditOperations: ReturnType<typeof vi.fn>
|
||||
pushUndoStop: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
@@ -20,18 +21,27 @@ function createHarness(
|
||||
endColumn: 5
|
||||
}))
|
||||
const pushEditOperations = vi.fn()
|
||||
const applyEdits = vi.fn()
|
||||
const model = {
|
||||
getValue,
|
||||
getEOL: () => eol,
|
||||
getFullModelRange,
|
||||
pushEditOperations
|
||||
pushEditOperations,
|
||||
applyEdits
|
||||
} as unknown as editor.ITextModel
|
||||
const pushUndoStop = vi.fn()
|
||||
const editorInstance = {
|
||||
getModel: () => model,
|
||||
pushUndoStop
|
||||
} as unknown as editor.IStandaloneCodeEditor
|
||||
return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop }
|
||||
return {
|
||||
editorInstance,
|
||||
getValue,
|
||||
getFullModelRange,
|
||||
applyEdits,
|
||||
pushEditOperations,
|
||||
pushUndoStop
|
||||
}
|
||||
}
|
||||
|
||||
describe('monaco content sync', () => {
|
||||
@@ -60,6 +70,26 @@ describe('monaco content sync', () => {
|
||||
expect(harness.pushUndoStop).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('inserts a read-only live-tail suffix without recording undo history', () => {
|
||||
const harness = createHarness('first\nsecond\nlast')
|
||||
|
||||
syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext', 'read-only-live-tail')
|
||||
|
||||
expect(harness.applyEdits).toHaveBeenCalledWith([
|
||||
{
|
||||
range: {
|
||||
startLineNumber: 3,
|
||||
startColumn: 5,
|
||||
endLineNumber: 3,
|
||||
endColumn: 5
|
||||
},
|
||||
text: '\nnext'
|
||||
}
|
||||
])
|
||||
expect(harness.pushEditOperations).not.toHaveBeenCalled()
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing for identical content', () => {
|
||||
const harness = createHarness('unchanged')
|
||||
|
||||
@@ -127,6 +157,22 @@ describe('monaco content sync', () => {
|
||||
expect(harness.pushUndoStop).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['same-length rewrite', 'before', 'after!'],
|
||||
['prefix mismatch', 'before', 'changed content'],
|
||||
['truncation', 'longer content', 'short']
|
||||
])('non-undoingly replaces a read-only live-tail %s', (_label, initialContent, nextContent) => {
|
||||
const harness = createHarness(initialContent)
|
||||
|
||||
syncContentUpdate(harness.editorInstance, nextContent, 'read-only-live-tail')
|
||||
|
||||
expect(harness.applyEdits).toHaveBeenCalledWith([
|
||||
{ range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent }
|
||||
])
|
||||
expect(harness.pushEditOperations).not.toHaveBeenCalled()
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles a stale retained model on mount without undo stops', () => {
|
||||
const harness = createHarness('stale')
|
||||
|
||||
@@ -136,6 +182,16 @@ describe('monaco content sync', () => {
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles a stale read-only live-tail model on mount without undo history', () => {
|
||||
const harness = createHarness('stale')
|
||||
|
||||
expect(syncContentOnMount(harness.editorInstance, 'fresh', 'read-only-live-tail')).toBe(true)
|
||||
|
||||
expect(harness.applyEdits).toHaveBeenCalledTimes(1)
|
||||
expect(harness.pushEditOperations).not.toHaveBeenCalled()
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing on mount when content already matches', () => {
|
||||
const harness = createHarness('same')
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
export type MonacoContentSyncMode = 'undoable' | 'read-only-live-tail'
|
||||
|
||||
function normalizeToModelEol(content: string, model: editor.ITextModel): string {
|
||||
const eol = model.getEOL()
|
||||
// Why: Monaco normalizes model line endings, while filesystem content keeps
|
||||
@@ -10,28 +12,41 @@ function normalizeToModelEol(content: string, model: editor.ITextModel): string
|
||||
return content.replace(/\r\n|\r|\n/g, eol)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a Monaco model's text via pushEditOperations so the change
|
||||
* participates in the undo stack (unlike setValue, which blows it away).
|
||||
*/
|
||||
function applyModelEdit(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
model: editor.ITextModel,
|
||||
edit: editor.IIdentifiedSingleEditOperation,
|
||||
mode: MonacoContentSyncMode,
|
||||
withUndoStops: boolean
|
||||
): void {
|
||||
if (mode === 'read-only-live-tail') {
|
||||
// Why: live-tail updates are machine-owned and cannot be undone by users;
|
||||
// recording them would retain the growing log again in Monaco's undo service.
|
||||
model.applyEdits([edit])
|
||||
return
|
||||
}
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
model.pushEditOperations([], [edit], () => null)
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
}
|
||||
|
||||
function replaceModelContent(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
model: editor.ITextModel,
|
||||
currentContent: string,
|
||||
content: string,
|
||||
mode: MonacoContentSyncMode,
|
||||
withUndoStops: boolean
|
||||
): void {
|
||||
if (currentContent === content) {
|
||||
return
|
||||
}
|
||||
const fullRange = model.getFullModelRange()
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
model.pushEditOperations([], [{ range: fullRange, text: content }], () => null)
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
applyModelEdit(editorInstance, model, { range: fullRange, text: content }, mode, withUndoStops)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +61,8 @@ function replaceModelContent(
|
||||
*/
|
||||
export function syncContentOnMount(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
content: string
|
||||
content: string,
|
||||
mode: MonacoContentSyncMode = 'undoable'
|
||||
): boolean {
|
||||
const model = editorInstance.getModel()
|
||||
if (!model) {
|
||||
@@ -60,7 +76,7 @@ export function syncContentOnMount(
|
||||
// Why: no undo stop on mount — the retained model's text was already the
|
||||
// user's last-known state, and adding an undo entry here would make Cmd+Z
|
||||
// revert to the pre-remount text, which is confusing.
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, false)
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, false)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -74,7 +90,8 @@ export function syncContentOnMount(
|
||||
*/
|
||||
export function syncContentUpdate(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
content: string
|
||||
content: string,
|
||||
mode: MonacoContentSyncMode = 'undoable'
|
||||
): void {
|
||||
const model = editorInstance.getModel()
|
||||
if (!model) {
|
||||
@@ -83,7 +100,7 @@ export function syncContentUpdate(
|
||||
const currentContent = model.getValue()
|
||||
const normalizedContent = normalizeToModelEol(content, model)
|
||||
if (currentContent.length === normalizedContent.length) {
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, true)
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true)
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -93,24 +110,22 @@ export function syncContentUpdate(
|
||||
// Why: preserving the existing prefix lets Monaco retain viewport,
|
||||
// selection, find-widget, and tokenization state above a live-file append.
|
||||
const fullRange = model.getFullModelRange()
|
||||
editorInstance.pushUndoStop()
|
||||
model.pushEditOperations(
|
||||
[],
|
||||
[
|
||||
{
|
||||
range: {
|
||||
startLineNumber: fullRange.endLineNumber,
|
||||
startColumn: fullRange.endColumn,
|
||||
endLineNumber: fullRange.endLineNumber,
|
||||
endColumn: fullRange.endColumn
|
||||
},
|
||||
text: normalizedContent.slice(currentContent.length)
|
||||
}
|
||||
],
|
||||
() => null
|
||||
applyModelEdit(
|
||||
editorInstance,
|
||||
model,
|
||||
{
|
||||
range: {
|
||||
startLineNumber: fullRange.endLineNumber,
|
||||
startColumn: fullRange.endColumn,
|
||||
endLineNumber: fullRange.endLineNumber,
|
||||
endColumn: fullRange.endColumn
|
||||
},
|
||||
text: normalizedContent.slice(currentContent.length)
|
||||
},
|
||||
mode,
|
||||
true
|
||||
)
|
||||
editorInstance.pushUndoStop()
|
||||
return
|
||||
}
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, true)
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment happy-dom
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { syncContentUpdate } from './monaco-content-sync'
|
||||
|
||||
const models: monaco.editor.ITextModel[] = []
|
||||
|
||||
function createEditor(initialContent: string): {
|
||||
editorInstance: monaco.editor.IStandaloneCodeEditor
|
||||
model: monaco.editor.ITextModel
|
||||
} {
|
||||
const model = monaco.editor.createModel(initialContent, 'plaintext')
|
||||
models.push(model)
|
||||
return {
|
||||
model,
|
||||
editorInstance: {
|
||||
getModel: () => model,
|
||||
pushUndoStop: () => {
|
||||
model.pushStackElement()
|
||||
return true
|
||||
}
|
||||
} as unknown as monaco.editor.IStandaloneCodeEditor
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const model of models.splice(0)) {
|
||||
model.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Monaco external-content undo history', () => {
|
||||
it('does not make a read-only live-tail append undoable', () => {
|
||||
const { editorInstance, model } = createEditor('first line')
|
||||
|
||||
syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail')
|
||||
|
||||
expect(model.getValue()).toBe('first line\nappended')
|
||||
expect(model.canUndo()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps ordinary external updates undoable', async () => {
|
||||
const { editorInstance, model } = createEditor('editable')
|
||||
|
||||
syncContentUpdate(editorInstance, 'external update')
|
||||
|
||||
expect(model.canUndo()).toBe(true)
|
||||
await model.undo()
|
||||
expect(model.getValue()).toBe('editable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { bench, expect } from 'vitest'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { syncContentUpdate, type MonacoContentSyncMode } from './monaco-content-sync'
|
||||
|
||||
const MEBIBYTE = 1024 * 1024
|
||||
const BATCH_COUNT = 5
|
||||
const BATCH_BYTES = 10 * MEBIBYTE
|
||||
|
||||
type UndoStackElement = { heapSize: () => number }
|
||||
type UndoRedoService = {
|
||||
getElements: (resource: monaco.Uri) => { past: UndoStackElement[]; future: UndoStackElement[] }
|
||||
}
|
||||
|
||||
function undoHistoryBytes(model: monaco.editor.ITextModel): number {
|
||||
const service = (model as unknown as { _undoRedoService: UndoRedoService })._undoRedoService
|
||||
const elements = service.getElements(model.uri)
|
||||
return [...elements.past, ...elements.future].reduce(
|
||||
(total, element) => total + element.heapSize(),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
async function forceGcAndSettle(): Promise<void> {
|
||||
const gc = (globalThis as { gc?: () => void }).gc
|
||||
if (!gc) {
|
||||
throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc')
|
||||
}
|
||||
gc()
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function measureUndoRetention(mode: MonacoContentSyncMode): Promise<{
|
||||
arrayBufferDelta: number
|
||||
canUndo: boolean
|
||||
undoBytes: number
|
||||
}> {
|
||||
await forceGcAndSettle()
|
||||
const beforeArrayBuffers = process.memoryUsage().arrayBuffers
|
||||
const model = monaco.editor.createModel('', 'plaintext')
|
||||
const editorInstance = {
|
||||
getModel: () => model,
|
||||
pushUndoStop: () => {
|
||||
model.pushStackElement()
|
||||
return true
|
||||
}
|
||||
} as unknown as monaco.editor.IStandaloneCodeEditor
|
||||
let content = ''
|
||||
for (let batch = 0; batch < BATCH_COUNT; batch++) {
|
||||
content += String(batch % 10).repeat(BATCH_BYTES)
|
||||
syncContentUpdate(editorInstance, content, mode)
|
||||
}
|
||||
const canUndo = model.canUndo()
|
||||
const undoBytes = undoHistoryBytes(model)
|
||||
await forceGcAndSettle()
|
||||
const arrayBufferDelta = process.memoryUsage().arrayBuffers - beforeArrayBuffers
|
||||
model.dispose()
|
||||
await forceGcAndSettle()
|
||||
return { arrayBufferDelta, canUndo, undoBytes }
|
||||
}
|
||||
|
||||
bench(
|
||||
'50 MiB read-only live-tail undo retention',
|
||||
async () => {
|
||||
const undoable = await measureUndoRetention('undoable')
|
||||
const readOnlyLiveTail = await measureUndoRetention('read-only-live-tail')
|
||||
console.log(
|
||||
`[monaco-content-sync] 50 MiB undo retention ${JSON.stringify({ undoable, readOnlyLiveTail })}`
|
||||
)
|
||||
expect(undoable.canUndo).toBe(true)
|
||||
expect(undoable.undoBytes).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES)
|
||||
expect(undoable.arrayBufferDelta).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES)
|
||||
expect(readOnlyLiveTail.canUndo).toBe(false)
|
||||
expect(readOnlyLiveTail.undoBytes).toBe(0)
|
||||
expect(readOnlyLiveTail.arrayBufferDelta).toBe(0)
|
||||
},
|
||||
{ iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 }
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { editor, IRange, ISelection } from 'monaco-editor'
|
||||
|
||||
export type MonacoE2ESnapshot = {
|
||||
canUndo: boolean
|
||||
contentHeight: number
|
||||
scrollHeight: number
|
||||
scrollTop: number
|
||||
@@ -74,6 +75,7 @@ export function installMonacoE2EProbe(
|
||||
? lastLine
|
||||
: (model?.getLineContent(lastLineNumber - 1) ?? '')
|
||||
return {
|
||||
canUndo: model?.canUndo() ?? false,
|
||||
contentHeight: editorInstance.getContentHeight(),
|
||||
scrollHeight: editorInstance.getScrollHeight(),
|
||||
scrollTop: editorInstance.getScrollTop(),
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
WorkspaceSpaceWorktree
|
||||
} from '../../../../shared/workspace-space-types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { toast } from 'sonner'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -321,9 +322,11 @@ function UpdatedMetric({
|
||||
if (scannedAt === null) {
|
||||
return
|
||||
}
|
||||
// Refresh once immediately (unconditional, as before) so a rescan that lands
|
||||
// while hidden isn't shown stale, then pause the ongoing 60s tick while the
|
||||
// window is hidden — same visibility-gated pattern as useNow.
|
||||
setNow(Date.now())
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => window.clearInterval(timer)
|
||||
return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 60_000 })
|
||||
}, [scannedAt])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, GitBranch, Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { retryBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
|
||||
import { getCreationProgressLabel } from '@/lib/pending-worktree-creation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -31,8 +32,9 @@ export default function WorktreeCreationPanel({
|
||||
if (entryStatus !== 'creating') {
|
||||
return
|
||||
}
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
// Pause the 1s clock while the window is hidden so a backgrounded creation
|
||||
// panel stops re-rendering for ticks no one can see.
|
||||
return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 1000 })
|
||||
}, [entryStatus])
|
||||
if (!entry) {
|
||||
return null
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ test.describe('Agent Session History live log', () => {
|
||||
// metrics (macOS baseline 2,775,880px; Linux CI ~2,498,292px), so keep only a
|
||||
// generous content-height floor as a collapsed/truncated-render smoke check.
|
||||
expect(baseline.valueLength).toBe(fixture.initialLength)
|
||||
expect(baseline.canUndo).toBe(false)
|
||||
expect(baseline.contentHeight).toBeGreaterThanOrEqual(2_000_000)
|
||||
expect(baseline.visibleRanges.length).toBeGreaterThan(0)
|
||||
expect(baseline.find).toMatchObject({ open: true, query: ANCHOR_TOKEN })
|
||||
@@ -105,6 +106,7 @@ test.describe('Agent Session History live log', () => {
|
||||
const current = await readProbe(orcaPage)
|
||||
console.log(`[live-log-stability] anchor ${JSON.stringify({ batch, baseline, current })}`)
|
||||
expect(current.visibleRanges).toEqual(baseline.visibleRanges)
|
||||
expect(current.canUndo).toBe(false)
|
||||
expect(current.valueTail).toBe(suffix.trimEnd())
|
||||
expect(current.selection).toEqual(baseline.selection)
|
||||
expect(current.find).toEqual(baseline.find)
|
||||
@@ -173,14 +175,9 @@ async function seedSyntheticSession(
|
||||
}
|
||||
|
||||
async function openSessionHistory(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
store.getState().setRightSidebarOpen(true)
|
||||
store.getState().setRightSidebarTab('vault')
|
||||
})
|
||||
// Why: startup hydration can overwrite a direct store route; use the same
|
||||
// activity-bar action a user takes so the Agents panel wins that race.
|
||||
await page.getByRole('button', { name: 'Agents', exact: true }).click()
|
||||
await page.evaluate(async () => window.api.aiVault.listSessions({ force: true }))
|
||||
await page.getByRole('button', { name: 'Refresh Session History' }).click()
|
||||
}
|
||||
@@ -233,6 +230,7 @@ async function restoreAnchorState(
|
||||
}
|
||||
|
||||
async function readProbe(page: Page): Promise<{
|
||||
canUndo: boolean
|
||||
contentHeight: number
|
||||
filePath: string
|
||||
scrollTop: number
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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<boolean> {
|
||||
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<RuntimeStatus>('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: 'ignore'
|
||||
})
|
||||
activatingProcess.on('error', (error) => {
|
||||
console.error('[e2e] activating process failed to spawn:', error)
|
||||
})
|
||||
|
||||
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<RuntimeStatus>('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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user