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:
Brennan Benson
2026-07-13 20:06:10 -07:00
42 changed files with 1981 additions and 206 deletions
+1
View File
@@ -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'}`,
+89 -2
View File
@@ -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 () => {
+30 -5
View File
@@ -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,
+16 -1
View File
@@ -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',
+31 -1
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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)
})
})
+6 -2
View File
@@ -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' }
+95 -26
View File
@@ -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,
+28 -16
View File
@@ -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
+172 -2
View File
@@ -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)
+109 -7
View File
@@ -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)', () => {
+15 -19
View File
@@ -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
}
+1
View File
@@ -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'}`,
+2 -1
View File
@@ -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)}`]
}
}
+5 -1
View File
@@ -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()
+10
View File
@@ -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
+8
View File
@@ -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