From 0f522c35e58acad011c925e33c76849c3d7eeb41 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:30:48 -0700 Subject: [PATCH 1/9] fix(remote): gate empty session inventory on host authority (#16546) --- config/reliability-gates.jsonc | 17 +- src/cli/runtime/websocket-transport.test.ts | 2 + .../terminal-host-agent-session.test.ts | 43 ++ src/main/runtime/orca-runtime.ts | 190 +++++++- .../session-tabs-inventory-rpc.test.ts | 402 +++++++++++++++++ .../rpc/methods/session-tabs-inventory.ts | 110 +++++ .../runtime/rpc/methods/session-tabs.test.ts | 2 + src/main/runtime/rpc/methods/session-tabs.ts | 82 +--- ...session-tabs-inventory-publication.test.ts | 416 ++++++++++++++++++ .../direct-ssh-hydration-fanout.test.ts | 3 +- .../src/runtime/host-live-terminal-probe.ts | 14 +- ...sion-mirror-empty-inventory-settle.test.ts | 284 +++++++++++- ...t-session-mirror-frame-ordering-harness.ts | 6 + ...n-mirror-hydration-frame-ordering.test.tsx | 18 + .../host-session-mirror-settle-census.test.ts | 6 +- ...sion-mirror-settle-receipt-frames.test.tsx | 31 ++ ...runtime-browser-capability-cleanup.test.ts | 1 + ...e-session-browser-client-placement.test.ts | 2 + ...ime-session-browser-create-failure.test.ts | 2 + ...ntime-session-browser-create-focus.test.ts | 2 + ...ion-browser-create-split-placement.test.ts | 2 + ...ession-browser-create-staged-focus.test.ts | 2 + ...owser-create-staged-hosting-intent.test.ts | 2 + ...ime-session-browser-create-staging.test.ts | 2 + ...ession-browser-placement-staleness.test.ts | 2 + ...runtime-session-tab-activate-close.test.ts | 2 + .../web-runtime-session-tab-move.test.ts | 2 + .../web-runtime-session-tab-props.test.ts | 2 + ...me-session-terminal-host-authority.test.ts | 2 + ...ime-session-terminal-legacy-create.test.ts | 2 + ...e-session-terminal-pane-delegation.test.ts | 2 + .../src/runtime/web-runtime-session.test.ts | 2 + .../src/runtime/web-runtime-session.ts | 21 +- ...ssion-tabs-sync-window-visibility.test.tsx | 8 +- .../src/runtime/web-session-tabs-sync.ts | 245 +++++++++-- .../src/store/slices/runtime-status.ts | 7 + src/shared/protocol-version.ts | 3 + .../remote-runtime-client-capabilities.ts | 2 + ...minal-host-restart-background-sync.spec.ts | 9 + ...empty-inventory-daemon-oracle.unit.test.ts | 236 ++++++++++ 40 files changed, 2045 insertions(+), 143 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tabs-inventory.ts create mode 100644 src/main/runtime/session-tabs-inventory-publication.test.ts create mode 100644 tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 8a6dc2b0d96..ac37e3760b9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8124,6 +8124,7 @@ "hidden remote worktree retention", "remote terminal reveal and input", "paired host relaunch with preserved daemon PTYs", + "paired host session inventory publication authority", "manual server disconnect" ], "platforms": ["macos", "linux", "windows"], @@ -8135,13 +8136,14 @@ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. An empty headed session-tab inventory is authoritative only after the current renderer graph generation publishes a complete inventory and every execution host answers the PTY census; unpublished, resync-pending, transport-failed, and partially scoped inventories remain unverifiable, while genuinely authoritative empty and non-empty inventories settle and a scope-less legacy empty keeps its pre-authority best-effort settle so outdated hosts retain agent auto-resume. The unpublished-empty regression must keep the resume dispatch parked with exactly one daemon process/writer; restoring unconditional empty hydration must release one dispatch and create two live writers. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", @@ -8174,6 +8176,11 @@ "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts", "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "src/main/runtime/session-tabs-inventory-publication.test.ts", + "src/main/runtime/rpc/methods/session-tabs.test.ts", + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts", + "src/main/daemon/terminal-host-agent-session.test.ts", + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts", "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts", "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts", @@ -8257,6 +8264,14 @@ "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "assertions": ["retries an existing subscriber when provider inventory becomes ready"] }, + { + "file": "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts", + "assertions": [ + "unpublished empty inventory keeps resume dispatch at zero with the original daemon session, process id, and writer as the sole live writer", + "an authoritative empty inventory settles the mirror and releases exactly one resume dispatch with zero liveness probes", + "re-enabling unconditional empty hydration makes the identical oracle red with one resume dispatch, two session ids, two process ids, and two writer calls" + ] + }, { "file": "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", "assertions": [ diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 985953fd4a9..20328a9b614 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -22,6 +22,7 @@ import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, @@ -69,6 +70,7 @@ describe('CLI remote WebSocket transport', () => { expect.objectContaining({ clientCapabilities: [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/src/main/daemon/terminal-host-agent-session.test.ts b/src/main/daemon/terminal-host-agent-session.test.ts index b087e579ae4..1cf929fb93f 100644 --- a/src/main/daemon/terminal-host-agent-session.test.ts +++ b/src/main/daemon/terminal-host-agent-session.test.ts @@ -90,6 +90,49 @@ describe('TerminalHost agent-session claims', () => { expect(spawnSubprocess).toHaveBeenCalledOnce() }) + it('cannot adopt a live session that predates provider-session claims', async () => { + const subprocesses: ReturnType[] = [] + host = new TerminalHost({ + spawnSubprocess: () => { + const handle = createClaimedSubprocess() + subprocesses.push(handle) + return handle + } + }) + await host.createOrAttach({ + sessionId: 'renderer-owned-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const resumed = await host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() }, + agentSessionEnsure: { claim, surface } + }) + + expect(resumed.agentSessionEnsure?.disposition).toBe('created') + expect( + host + .listSessions() + .map(({ sessionId }) => sessionId) + .sort() + ).toEqual(['renderer-owned-live-session', 'replacement-resume-session']) + expect(subprocesses).toHaveLength(2) + host.write('renderer-owned-live-session', 'original-writer') + host.write('replacement-resume-session', 'replacement-writer') + expect(subprocesses[0]?.write).toHaveBeenCalledWith('original-writer') + expect(subprocesses[1]?.write).toHaveBeenCalledWith('replacement-writer') + for (const handle of subprocesses) { + handle.exit() + } + }) + it('rejects a competing claim without replacing the winning stream', async () => { let releaseSpawn: () => void = () => {} const spawnGate = new Promise((resolve) => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b8196482870..c33d741bc29 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -655,6 +655,7 @@ import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' @@ -3254,7 +3255,8 @@ export class OrcaRuntimeService { // reconnect-scan bounding for sequential soft freezes. private readonly terminalFocusNavigationCoalescer = new TerminalFocusNavigationCoalescer() - private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null + private pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = + null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3269,6 +3271,8 @@ export class OrcaRuntimeService { private syntheticTerminalHandles = new Set() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] + private sessionTabsInventoryPublicationEpoch: number | null = null + private sessionTabsInventoryWaiters = new Set<() => void>() private waitersByHandle = new Map>() private ptyExitListenersByPtyId = new Map void>>() private ptyController: RuntimePtyController | null = null @@ -6449,7 +6453,9 @@ export class OrcaRuntimeService { (process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' || capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || - capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) + capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && + (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) ) if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) @@ -7327,7 +7333,19 @@ export class OrcaRuntimeService { this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) } } + // Why: only the authoritative window grants inventory authority; headless qualifies because it becomes authoritative before its next sync. + const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId this.markGraphReady(windowId) + if ( + isAuthoritativeGraphPublisher && + (windowId === HEADLESS_RUNTIME_WINDOW_ID || graph.mobileSessionTabs !== undefined) + ) { + if (mobileSessionResyncWorktrees.size === 0) { + this.markSessionTabsInventoryPublished() + } else { + this.sessionTabsInventoryPublicationEpoch = null + } + } if (rendererGeneration !== undefined) { this.rendererGeneration = rendererGeneration } @@ -7439,6 +7457,13 @@ export class OrcaRuntimeService { async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { + return (await this.collectAllMobileSessionTabs(clientNavigationId)).snapshots + } + + private async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + }> { for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -7446,13 +7471,132 @@ export class OrcaRuntimeService { }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - await this.refreshMobileSessionPtyRecords() - return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => - this.projectMobileSessionTabsForClient( - this.toMobileSessionTabsResult(snapshot), - clientNavigationId - ) - ) + const ptyInventory = await this.refreshMobileSessionPtyInventory() + this.restoreLivePairedRendererSessionOwnedMobileTerminals(null) + return { + snapshots: [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => + this.projectMobileSessionTabsForClient( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + ), + ptyInventory + } + } + + async listAllMobileSessionTabsInventory( + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + this.assertSessionTabsInventoryRequestActive(signal) + const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + const primed = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if ( + primedPublicationEpoch !== null && + this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch + ) { + return await this.settleSessionTabsInventory(primed, clientNavigationId, signal) + } + while (true) { + const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() + if (publicationEpoch === null) { + await this.waitForSessionTabsInventoryPublication(signal) + continue + } + const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) { + return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal) + } + } + } + + // Why: a failed census only invalidates the emptiness verdict, never the + // list. An incomplete census usually means a concurrent scan invalidated + // this one mid-relaunch and daemon-backed tabs could not be restored yet, so + // retry the collection once; a still-incomplete retry serves its snapshots + // unlabeled rather than erroring the request. + private async settleSessionTabsInventory( + inventory: { + snapshots: RuntimeMobileSessionTabsResult[] + ptyInventory: PtyControllerInventory | null + }, + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) { + return { snapshots: inventory.snapshots, authoritative: true } + } + const retried = await this.collectAllMobileSessionTabs(clientNavigationId) + this.assertSessionTabsInventoryRequestActive(signal) + // Why: the retry ran outside the epoch fence, so it never claims authority. + return { snapshots: retried.snapshots } + } + + supportsAuthoritativeSessionTabsInventory(): boolean { + return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' + } + + private assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('client_disconnected') + } + } + + private isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean { + if (!inventory) { + return false + } + const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds) + return ![...knownHostIds].some((hostId) => { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId) + }) + } + + private waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise { + if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const cleanup = (): void => { + this.sessionTabsInventoryWaiters.delete(onPublished) + signal?.removeEventListener('abort', onAbort) + } + const onPublished = (): void => { + cleanup() + resolve() + } + const onAbort = (): void => { + cleanup() + reject(new Error('client_disconnected')) + } + this.sessionTabsInventoryWaiters.add(onPublished) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } else if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { + onPublished() + } + }) + } + + private getAuthoritativeSessionTabsInventoryEpoch(): number | null { + return this.graphStatus === 'ready' && + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch + ? this.rendererGraphEpoch + : null + } + + private markSessionTabsInventoryPublished(): void { + if (this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch) { + return + } + this.sessionTabsInventoryPublicationEpoch = this.rendererGraphEpoch + for (const publish of [...this.sessionTabsInventoryWaiters]) { + publish() + } } private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( @@ -9185,19 +9329,29 @@ export class OrcaRuntimeService { private async refreshMobileSessionPtyRecords( targetWorktreeId: string | null = null ): Promise | null> { + const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId) + return inventory ? new Set(inventory.livePtyIds) : null + } + + private async refreshMobileSessionPtyInventory( + targetWorktreeId: string | null = null + ): Promise { if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - const pending = this.pendingMobileSessionPtyInventoryRefresh + // Non-floating refreshes all query the aggregate controller inventory; + // coalesce targeted and all-worktree callers so they cannot invalidate + // one another through the shared aggregate generation fence. + const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh if (pending) { return pending } // Why: reconnect exit bursts share one authoritative daemon inventory // instead of multiplying a full cross-generation list RPC per stale tab. const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { - if (this.pendingMobileSessionPtyInventoryRefresh === refresh) { - this.pendingMobileSessionPtyInventoryRefresh = null + if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) { + this.pendingMobileSessionPtyAggregateInventoryRefresh = null } }) - this.pendingMobileSessionPtyInventoryRefresh = refresh + this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh return refresh } return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) @@ -9205,14 +9359,14 @@ export class OrcaRuntimeService { private async performMobileSessionPtyRecordsRefresh( targetWorktreeId: string | null - ): Promise | null> { + ): Promise { if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { return null } // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() - return await this.refreshPtyWorktreeRecordsFromController( + return await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, isFloatingWorkspace ? targetWorktreeId : null ) @@ -31730,6 +31884,8 @@ export class OrcaRuntimeService { return false } if (fence.recovery === 'renderer') { + const restoresPublishedInventory = + this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - 1 this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(true) for (const leaf of this.leaves.values()) { @@ -31737,6 +31893,9 @@ export class OrcaRuntimeService { } this.reconcilePtyIncarnationHandles() this.refreshWritableFlags() + if (restoresPublishedInventory) { + this.markSessionTabsInventoryPublished() + } return true } this.graphReloadLifecycle.begin(windowId) @@ -31849,6 +32008,7 @@ export class OrcaRuntimeService { this.clearPtyIncarnationHandles() this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() + this.markSessionTabsInventoryPublished() } private assertGraphReady(): void { diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts new file mode 100644 index 00000000000..2834c55b36d --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { OrcaRuntimeService } from '../../orca-runtime' +import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { SESSION_TAB_METHODS } from './session-tabs' +import { listSessionTabsInventory } from './session-tabs-inventory' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('session tabs inventory RPC methods', () => { + it('withholds an old-client list until the host inventory is authoritative', async () => { + let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ snapshots: []; authoritative: true }>((resolve) => { + resolveInventory = resolve + }) + ) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + for (let index = 0; index < 20 && !resolveInventory; index += 1) { + await Promise.resolve() + } + expect(messages).toEqual([]) + + resolveInventory({ snapshots: [], authoritative: true }) + await pending + + expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [] }) + }) + + // Why: a census failure only invalidates the emptiness verdict — the runtime + // degrades to the same scan unlabeled, so a capable client keeps the list + // (e.g. one configured-but-disconnected SSH host) without a second collect. + it('serves a capable client an unlabeled list when the census fails', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [ + { + worktree: 'wt-local', + publicationEpoch: 'epoch-local', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + })), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { + clientKind: 'runtime', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + const result = JSON.parse(messages[0]!).result + expect(result.snapshots).toEqual([expect.objectContaining({ worktree: 'wt-local' })]) + expect(result.authoritative).toBeUndefined() + }) + + it('propagates disconnect errors to legacy clients', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => { + throw new Error('client_disconnected') + }), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + expect(JSON.parse(messages[0]!)).toMatchObject({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + }) + + it('labels an authoritative inventory only for a client that negotiated it', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [], + authoritative: true as const + })) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { + clientKind: 'runtime', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + + expect(JSON.parse(messages[0]!).result).toEqual({ snapshots: [], authoritative: true }) + }) + + it('serves an old client the degraded scan when terminal liveness cannot be proven', async () => { + const legacyListAll = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(async () => ({ + snapshots: [ + { + worktree: 'wt-local', + publicationEpoch: 'epoch-local', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + })), + listAllMobileSessionTabs: legacyListAll + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.listAll'), + (message) => messages.push(message), + { clientKind: 'runtime' } + ) + + expect(legacyListAll).not.toHaveBeenCalled() + expect(JSON.parse(messages[0]!).result).toEqual({ + snapshots: [expect.objectContaining({ worktree: 'wt-local' })] + }) + }) + + it('forwards the caller signal so a disconnected request never scans', async () => { + const inventory = vi.fn(async (_navigationId?: string, signal?: AbortSignal) => { + if (signal?.aborted) { + throw new Error('client_disconnected') + } + return { snapshots: [] } + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: inventory + } as unknown as OrcaRuntimeService + const controller = new AbortController() + controller.abort() + + await expect(listSessionTabsInventory({ runtime, signal: controller.signal })).rejects.toThrow( + 'client_disconnected' + ) + expect(inventory).toHaveBeenCalledWith(undefined, controller.signal) + }) + + it('lets the final authoritative inventory subsume prior-epoch updates', async () => { + let resolveInventory!: (value: { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative: true + }) => void + const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + authoritative: true + }>((resolve) => { + resolveInventory = resolve + }) + ), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + listeners.push(listener) + return vi.fn() + } + ), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-authoritative', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + await Promise.resolve() + listeners[0]?.({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-before-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + expect(messages).toEqual([]) + + resolveInventory({ + snapshots: [ + { + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ], + authoritative: true + }) + await pending + listeners[0]?.({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + + expect(messages.map((message) => JSON.parse(message).result)).toEqual([ + { + type: 'snapshots', + snapshots: [ + expect.objectContaining({ + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 1 + }) + ], + authoritative: true + }, + expect.objectContaining({ + type: 'updated', + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 2 + }) + ]) + }) + + it('lets authoritative empty inventory win over prior-epoch updates', async () => { + let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void + const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn( + () => + new Promise<{ snapshots: []; authoritative: true }>((resolve) => { + resolveInventory = resolve + }) + ), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + listeners.push(listener) + return vi.fn() + } + ), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + const snapshot = (snapshotVersion: number): RuntimeMobileSessionTabsResult => ({ + worktree: 'wt-buffered', + publicationEpoch: 'epoch-buffered', + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-authoritative-empty', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + await Promise.resolve() + listeners[0]?.(snapshot(2)) + listeners[0]?.(snapshot(3)) + + resolveInventory({ snapshots: [], authoritative: true }) + await pending + + expect(messages.map((message) => JSON.parse(message).result)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true } + ]) + }) + + it('does no subscription work for an already-cancelled stream', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(), + onMobileSessionTabsChanged: vi.fn(), + registerSubscriptionCleanup: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const controller = new AbortController() + const messages: string[] = [] + controller.abort() + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { signal: controller.signal } + ) + + expect(runtime.onMobileSessionTabsChanged).not.toHaveBeenCalled() + expect(runtime.listAllMobileSessionTabsInventory).not.toHaveBeenCalled() + expect(messages.map((message) => JSON.parse(message))).toEqual([ + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + ]) + }) + + it('aborts and removes a publication waiter when the stream is cleaned up', async () => { + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const messages: string[] = [] + const pending = dispatcher.dispatchStreaming( + makeRequest('session.tabs.subscribeAll'), + (message) => messages.push(message), + { + connectionId: 'conn-cancel', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + } + ) + const waiters = (runtime as unknown as { sessionTabsInventoryWaiters: Set<() => void> }) + .sessionTabsInventoryWaiters + for (let index = 0; index < 20 && waiters.size === 0; index += 1) { + await Promise.resolve() + } + expect(waiters.size).toBe(1) + + runtime.cleanupSubscription('session.tabs:conn-cancel:*:req-1') + await pending + + expect(waiters.size).toBe(0) + expect(messages.map((message) => JSON.parse(message))).toEqual([ + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ code: 'runtime_error' }) + }) + ]) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts new file mode 100644 index 00000000000..f47c2251f6e --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -0,0 +1,110 @@ +import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { RpcContext } from '../core' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' +import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' + +type SessionTabsInventory = { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: true +} + +function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean { + return ( + context.clientCapabilities?.includes( + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY + ) === true + ) +} + +export function projectSessionTabsForClient( + snapshot: RuntimeMobileSessionTabsResult, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: Parameters[2] +): RuntimeMobileSessionTabsResult { + return projectSessionTabBrowserPlacements( + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + clientCapabilities + ) +} + +function projectInventory( + inventory: SessionTabsInventory, + context: RpcContext +): SessionTabsInventory { + return { + snapshots: inventory.snapshots.map((snapshot) => + projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + ), + ...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context) + ? { authoritative: true as const } + : {}) + } +} + +export async function listSessionTabsInventory(context: RpcContext): Promise { + const { runtime, pairedDeviceId, signal } = context + // Why: a failed census degrades inside the runtime to the same scan without + // the authoritative label, so no client ever pays a second full collection. + const inventory = runtime.supportsAuthoritativeSessionTabsInventory() + ? await runtime.listAllMobileSessionTabsInventory(pairedDeviceId, signal) + : { snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) } + return projectInventory(inventory, context) +} + +export async function subscribeSessionTabsInventory( + context: RpcContext, + emit: (result: unknown) => void +): Promise { + const { runtime, connectionId, requestId, pairedDeviceId } = context + if (context.signal?.aborted) { + throw new Error('client_disconnected') + } + const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` + const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix + const inventoryController = new AbortController() + const abortInventory = (): void => inventoryController.abort() + context.signal?.addEventListener('abort', abortInventory, { once: true }) + let initialized = false + let closed = false + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + if (!initialized) { + // Why: the final authoritative scan subsumes these; replaying a prior epoch afterward resurrects stale host state. + return + } + emit({ + type: 'updated', + ...projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + }) + }, pairedDeviceId) + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + inventoryController.abort() + unsubscribe() + if (initialized) { + emit({ type: 'end' }) + } + }, + connectionId + ) + if (closed) { + context.signal?.removeEventListener('abort', abortInventory) + return + } + const inventory = await listSessionTabsInventory({ + ...context, + signal: inventoryController.signal + }) + .catch((error) => { + runtime.cleanupSubscription(subscriptionId) + throw error + }) + .finally(() => context.signal?.removeEventListener('abort', abortInventory)) + if (closed) { + return + } + emit({ type: 'snapshots', ...inventory }) + initialized = true +} diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 488a9da35a3..18f130bd869 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -651,6 +651,7 @@ describe('session tab RPC methods', () => { tabs: [] } ]), + supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn((listener: (snapshot: unknown) => void) => { listeners.push(listener) return unsubscribe @@ -697,6 +698,7 @@ describe('session tab RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', listAllMobileSessionTabs: vi.fn(() => []), + supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn(() => vi.fn()), registerSubscriptionCleanup: vi.fn() } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 1f459debd50..5b9958aeee8 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -7,23 +7,14 @@ import { WorktreeTabSelector } from './session-tabs-schemas' import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' -import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' -import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + listSessionTabsInventory, + projectSessionTabsForClient, + subscribeSessionTabsInventory +} from './session-tabs-inventory' import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' -function projectSessionTabsForClient( - snapshot: RuntimeMobileSessionTabsResult, - clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] -) { - return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), - clientCapabilities - ) -} - export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', @@ -38,11 +29,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({ - snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) => - projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - ) - }) + handler: async (_params, context) => listSessionTabsInventory(context) }), ...SESSION_TAB_MUTATION_METHODS, ...SESSION_TAB_CLOSE_METHODS, @@ -155,62 +142,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async ( - _params, - { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, - emit - ) => { - let unsubscribe = (): void => {} - let closed = false - // Why: initial listAll errors should return one RPC error, not a leaked - // subscription cleanup that later emits a stray end frame. - let initialized = false - const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` - const subscriptionId = requestId ? `${cleanupPrefix}:${requestId}` : cleanupPrefix - // Why: shared-control can carry multiple all-tab subscribers on one - // socket; include the RPC id so closing one does not evict siblings. - runtime.registerSubscriptionCleanup( - subscriptionId, - () => { - closed = true - unsubscribe() - if (initialized) { - emit({ type: 'end' }) - } - }, - connectionId - ) - - if (closed) { - return - } - const snapshots = await Promise.resolve( - runtime.listAllMobileSessionTabs(pairedDeviceId) - ).catch((error) => { - runtime.cleanupSubscription(subscriptionId) - throw error - }) - if (closed) { - return - } - emit({ - type: 'snapshots', - snapshots: snapshots.map((snapshot) => - projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - ) - }) - initialized = true - - if (closed) { - return - } - unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emit({ - type: 'updated', - ...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) - }) - }, pairedDeviceId) - } + handler: async (_params, context, emit) => subscribeSessionTabsInventory(context, emit) }), defineMethod({ name: 'session.tabs.unsubscribeAll', diff --git a/src/main/runtime/session-tabs-inventory-publication.test.ts b/src/main/runtime/session-tabs-inventory-publication.test.ts new file mode 100644 index 00000000000..761504c273f --- /dev/null +++ b/src/main/runtime/session-tabs-inventory-publication.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type InventoryInternals = { + sessionTabsInventoryWaiters: Set<() => void> +} + +function createInventoryRuntime(): OrcaRuntimeService { + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ listProcesses: vi.fn(async () => []) } as never) + return runtime +} + +async function waitForInventoryWaiter(runtime: OrcaRuntimeService): Promise { + const internals = runtime as unknown as InventoryInternals + for (let index = 0; index < 20 && internals.sessionTabsInventoryWaiters.size === 0; index += 1) { + await Promise.resolve() + } + expect(internals.sessionTabsInventoryWaiters.size).toBe(1) +} + +describe('authoritative session tab inventory publication', () => { + it('primes daemon-backed inventory but withholds headed results until renderer publication', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + + expect(collect).toHaveBeenCalledTimes(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + await Promise.resolve() + expect(settled).toBe(false) + + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + expect(collect).toHaveBeenCalledTimes(2) + }) + + it('treats an initial headless graph as an authoritative empty publication', async () => { + const runtime = createInventoryRuntime() + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('returns a non-empty headed publication normally', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'repo::/worktree', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree: 'repo::/worktree', publicationEpoch: 'renderer-epoch' }] + }) + }) + + it('uses one inventory scan when the current publication is already authoritative', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ + snapshots: [], + authoritative: true + }) + + expect(collect).toHaveBeenCalledOnce() + }) + + it('retries publication churn until one inventory observes a stable epoch', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const inventory = { + snapshots: [], + ptyInventory: { + livePtyIds: new Set(), + allLivePtyIds: new Set(), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + } + let collections = 0 + vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ).mockImplementation(async () => { + collections += 1 + if (collections <= 3) { + expect(runtime.markRendererReloading(1)).not.toBeNull() + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + } + return inventory + }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ + snapshots: [], + authoritative: true + }) + expect(collections).toBe(4) + }) + + it('coalesces targeted and all-host PTY refreshes behind one aggregate census', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const emptyInventory = { + livePtyIds: new Set(), + allLivePtyIds: new Set(), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + let resolveRefresh: ((inventory: typeof emptyInventory) => void) | undefined + const internals = runtime as unknown as { + refreshMobileSessionPtyInventory: (targetWorktreeId?: string | null) => Promise + performMobileSessionPtyRecordsRefresh: (targetWorktreeId: string | null) => Promise + } + const perform = vi.spyOn(internals, 'performMobileSessionPtyRecordsRefresh').mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + + const allHosts = runtime.listAllMobileSessionTabsInventory() + await Promise.resolve() + const targeted = internals.refreshMobileSessionPtyInventory('repo::/target') + await Promise.resolve() + + expect(perform).toHaveBeenCalledOnce() + resolveRefresh?.(emptyInventory) + + await targeted + await expect(allHosts).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('retries once and serves an unlabeled scan when the PTY census is unavailable', async () => { + const runtime = new OrcaRuntimeService() + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] }) + // Why: the retry gives daemon-backed tabs a post-race restore chance. + expect(collect).toHaveBeenCalledTimes(2) + }) + + it('serves an unlabeled scan when an execution host is omitted from the census', async () => { + const runtime = createInventoryRuntime() + vi.spyOn( + runtime as unknown as { + listKnownExecutionHostIds: () => Set + }, + 'listKnownExecutionHostIds' + ).mockReturnValue(new Set(['local', 'ssh:box-1'])) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + await expect(runtime.listAllMobileSessionTabsInventory()).resolves.toEqual({ snapshots: [] }) + }) + + it('restores a renderer-owned PTY discovered by the authoritative inventory refresh', async () => { + const runtime = createInventoryRuntime() + const ptyId = 'pty-survived-host-relaunch' + const worktreeId = 'repo::/survived' + const tabId = '11111111-1111-4111-8111-111111111111' + const leafId = '22222222-2222-4222-8222-222222222222' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + ;( + runtime as unknown as { + pairedRendererSessionOwnedPtyIds: Set + } + ).pairedRendererSessionOwnedPtyIds.add(ptyId) + vi.spyOn( + runtime as unknown as { refreshMobileSessionPtyInventory: () => Promise }, + 'refreshMobileSessionPtyInventory' + ).mockImplementation(async () => { + runtime.registerPty(ptyId, worktreeId, null, { + tabId, + leafId, + incarnationId: 'incarnation-survived-host-relaunch' + }) + return { + livePtyIds: new Set([ptyId]), + allLivePtyIds: new Set([ptyId]), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } + }) + + const result = await runtime.listAllMobileSessionTabsInventory() + + expect(result).toMatchObject({ + authoritative: true, + snapshots: [ + { + worktree: worktreeId, + tabs: [ + expect.objectContaining({ + type: 'terminal', + parentTabId: tabId, + leafId, + ptyId + }) + ] + } + ] + }) + }) + + it('requires the current headed renderer generation to publish', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [] + }) + runtime.markRendererReloading(1) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + await Promise.resolve() + expect(settled).toBe(false) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'repo::/restored', + publicationEpoch: 'renderer-reloaded', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree: 'repo::/restored', publicationEpoch: 'renderer-reloaded' }] + }) + }) + + it('withholds authority while the renderer owes a requested worktree resync', async () => { + const runtime = createInventoryRuntime() + const worktree = 'repo::/resync' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + + const incomplete = runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [], + unchangedMobileSessionWorktrees: [worktree] + }) + expect(incomplete.mobileSessionResyncWorktrees).toEqual([worktree]) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree, + publicationEpoch: 'renderer-resynced', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + await expect(pending).resolves.toMatchObject({ + authoritative: true, + snapshots: [{ worktree, publicationEpoch: 'renderer-resynced' }] + }) + }) + + it('restores publication authority when a renderer reload is cancelled', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const fence = runtime.markRendererReloading(1) + if (!fence) { + throw new Error('expected renderer reload fence') + } + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + expect(runtime.markRendererReloadCancelled(1, fence)).toBe(true) + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('restores authoritative headless publication after a failed desktop promotion', async () => { + const runtime = createInventoryRuntime() + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.attachWindow(1) + const pending = runtime.listAllMobileSessionTabsInventory() + await waitForInventoryWaiter(runtime) + + runtime.markGraphReloadFailed(1, 'renderer-process-gone') + + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('removes a publication waiter when the request is cancelled', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + const controller = new AbortController() + const pending = runtime.listAllMobileSessionTabsInventory(undefined, controller.signal) + await waitForInventoryWaiter(runtime) + + controller.abort() + + await expect(pending).rejects.toThrow('client_disconnected') + expect((runtime as unknown as InventoryInternals).sessionTabsInventoryWaiters.size).toBe(0) + }) + + it('does not scan when the inventory request is already cancelled', async () => { + const runtime = createInventoryRuntime() + const collect = vi.spyOn( + runtime as unknown as { collectAllMobileSessionTabs: () => Promise }, + 'collectAllMobileSessionTabs' + ) + const controller = new AbortController() + controller.abort() + + await expect( + runtime.listAllMobileSessionTabsInventory(undefined, controller.signal) + ).rejects.toThrow('client_disconnected') + expect(collect).not.toHaveBeenCalled() + }) + + it('does not let a secondary renderer graph publish inventory authority', async () => { + const runtime = createInventoryRuntime() + runtime.attachWindow(1) + let settled = false + + const pending = runtime.listAllMobileSessionTabsInventory().then((result) => { + settled = true + return result + }) + await waitForInventoryWaiter(runtime) + + expect(() => + runtime.syncWindowGraph(2, { tabs: [], leaves: [], mobileSessionTabs: [] }) + ).toThrow('Runtime graph publisher does not match the authoritative window') + await Promise.resolve() + expect(settled).toBe(false) + expect( + (runtime as unknown as { sessionTabsInventoryPublicationEpoch: number | null }) + .sessionTabsInventoryPublicationEpoch + ).toBeNull() + + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) + await expect(pending).resolves.toEqual({ snapshots: [], authoritative: true }) + }) + + it('reports no authoritative support behind the e2e disable override', () => { + vi.stubEnv('ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY', '1') + try { + expect(createInventoryRuntime().supportsAuthoritativeSessionTabsInventory()).toBe(false) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts index 145300fddc6..e63ce38d2fa 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts @@ -64,8 +64,7 @@ describe('direct SSH hydration fanout', () => { api: { ui: {}, ssh: { - listTargets: () => - Promise.resolve(targets.map((id) => ({ id, label: id }))), + listTargets: () => Promise.resolve(targets.map((id) => ({ id, label: id }))), listRemovedTargetLabels: () => Promise.resolve({}), // A wedged relay answers nothing until the 30s RPC timeout fires. getState: ({ targetId }: { targetId: string }) => { diff --git a/src/renderer/src/runtime/host-live-terminal-probe.ts b/src/renderer/src/runtime/host-live-terminal-probe.ts index 1294fcb539e..8e5d8a14539 100644 --- a/src/renderer/src/runtime/host-live-terminal-probe.ts +++ b/src/renderer/src/runtime/host-live-terminal-probe.ts @@ -19,6 +19,7 @@ type RuntimeCall = (args: { method: string params: unknown timeoutMs: number + expectedEnvironmentPairingRevision?: number }) => Promise> type ValidTerminalListResult = RuntimeTerminalListResult & { @@ -52,7 +53,8 @@ function isTerminalListResult(value: unknown): value is ValidTerminalListResult async function probeHost( environmentId: string, - call: RuntimeCall + call: RuntimeCall, + expectedEnvironmentPairingRevision?: number ): Promise { const response = await call({ selector: environmentId, @@ -66,7 +68,8 @@ async function probeHost( requireFreshPtyLiveness: true, includeVisualLayouts: false }, - timeoutMs: 15_000 + timeoutMs: 15_000, + expectedEnvironmentPairingRevision }) if (response.ok === false || !isTerminalListResult(response.result)) { return 'unverifiable' @@ -86,14 +89,15 @@ async function probeHost( export function probeHostLiveTerminals( environmentId: string, call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args), - connectionGeneration = 0 + connectionGeneration = 0, + expectedEnvironmentPairingRevision?: number ): Promise { - const key = `${environmentId}\0${connectionGeneration}` + const key = `${environmentId}\0${connectionGeneration}\0${expectedEnvironmentPairingRevision ?? 'unknown'}` const existing = inFlightProbeByEnvironment.get(key) if (existing) { return existing } - const probe = probeHost(environmentId, call) + const probe = probeHost(environmentId, call, expectedEnvironmentPairingRevision) .catch((): HostLiveTerminalProbeVerdict => 'unverifiable') .finally(() => { if (inFlightProbeByEnvironment.get(key) === probe) { diff --git a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts index 0d590055917..8c815968eec 100644 --- a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts @@ -25,7 +25,14 @@ vi.mock('../store', () => ({ } })) -import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' +import { + getRuntimeEnvironmentRevision, + replaceRuntimeEnvironmentRevisions +} from './runtime-environment-revision' import { hasHostSessionMirrorHydrated, parkUntilHostSessionMirrorHydrates, @@ -35,11 +42,19 @@ import { clearHostLiveTerminalProbesForTests, probeHostLiveTerminals } from './host-live-terminal-probe' -import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync' +import { + applyWebSessionTabsStorePatch, + clearWebSessionTabsTrackingForEnvironment, + type HostSessionMirrorSettle +} from './web-session-tabs-sync' const WORKTREE = 'repo1::/path/wt1' -type HostCall = { method: string; params: Record } +type HostCall = { + method: string + params: Record + expectedEnvironmentPairingRevision?: number +} /** `null` models a failed probe: `unverifiable`, never `the host has none`. */ function stubPairedHost( @@ -71,11 +86,31 @@ function stubPairedHost( } /** The exact verdict both `listAll` sites build when the host publishes nothing. */ -function settleEmptyInventory(environmentId: string): void { - applyWebSessionTabsStorePatch(() => ({}), { +function settleEmptyInventory( + environmentId: string, + options: { + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } = {} +): void { + createEmptyInventorySettle(environmentId, options)() +} + +function createEmptyInventorySettle( + environmentId: string, + options: { + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } = {} +): HostSessionMirrorSettle { + return applyWebSessionTabsStorePatch(() => ({}), { frames: [], - fullInventory: { environmentId, publishedSnapshotCount: 0 } - })() + fullInventory: { environmentId, publishedSnapshotCount: 0, ...options } + }) } async function flushProbe(): Promise { @@ -87,6 +122,7 @@ describe('empty host inventory settling the session mirror', () => { resetHostSessionMirrorHydrationForTests() clearRuntimeEnvironmentConnectionGenerationsForTests() clearHostLiveTerminalProbesForTests() + replaceRuntimeEnvironmentRevisions([]) }) afterEach(() => { @@ -127,6 +163,94 @@ describe('empty host inventory settling the session mirror', () => { expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) }) + it('settles an authoritative empty inventory despite an unrelated live terminal', async () => { + const environmentId = 'env-authoritative-empty' + const calls = stubPairedHost(['unrelated-terminal']) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { authoritative: true }) + + expect(resumeSweeps).toBe(1) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) + expect(calls).toEqual([]) + }) + + it('rejects an authoritative receipt after the same id is re-paired', () => { + const environmentId = 'env-authoritative-repaired' + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentPairingRevision: 11 + }) + + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects an authoritative receipt from the previous connection generation', () => { + const environmentId = 'env-authoritative-reconnected' + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentConnectionGeneration: 1 + }) + + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects stale inventory even when recovery completes after reconnect', () => { + const environmentId = 'env-reconnected-before-receipt' + setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 2) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedEnvironmentConnectionGeneration: 1 + })() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('rejects an authoritative receipt after mirror tracking is cleared', () => { + const environmentId = 'env-authoritative-cleared' + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + const settle = createEmptyInventorySettle(environmentId, { + authoritative: true, + expectedTrackingGeneration: 0 + }) + + clearWebSessionTabsTrackingForEnvironment(environmentId) + settle() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + it('leaves waiters parked when the readiness probe fails', async () => { const environmentId = 'env-probe-failed' stubPairedHost(null) @@ -157,7 +281,7 @@ describe('empty host inventory settling the session mirror', () => { expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) }) - it('accepts a legacy zero-terminal result without host scope', async () => { + it('treats a legacy zero-terminal result without host scope as none', async () => { const result = await probeHostLiveTerminals( 'env-legacy-zero', vi.fn(async () => ({ @@ -189,6 +313,28 @@ describe('empty host inventory settling the session mirror', () => { expect(result).toBe('live') }) + // Why: pre-hostScope hosts cannot prove emptiness; parking them forever would + // strand auto-resume on every truly-empty legacy host, so their best-effort + // empty keeps settling as it did before authoritative inventories existed. + it('settles a scope-less legacy empty inventory', async () => { + const environmentId = 'env-legacy-scope-less' + const call = vi.fn(async () => ({ + ok: true, + result: { terminals: [], totalCount: 0, truncated: false } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId) + await flushProbe() + + expect(resumeSweeps).toBe(1) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true) + }) + it('rejects a present malformed host scope as unverifiable', async () => { const result = await probeHostLiveTerminals( 'env-malformed-scope', @@ -273,4 +419,126 @@ describe('empty host inventory settling the session mirror', () => { await expect(firstProbe).resolves.toBe('live') await expect(secondProbe).resolves.toBe('none') }) + + it('does not let a probe response cross a same-id pairing revision', async () => { + const environmentId = 'env-repaired' + let resolveProbe!: (response: RuntimeRpcResponse) => void + const call = vi.fn( + () => + new Promise>((resolve) => { + resolveProbe = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 }) + expect(call).toHaveBeenCalledWith( + expect.objectContaining({ expectedEnvironmentPairingRevision: 11 }) + ) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + expect(getRuntimeEnvironmentRevision(environmentId)).toBe(12) + resolveProbe({ + id: 'probe-repaired', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('does not reuse an in-flight probe across same-id pairing revisions', async () => { + const environmentId = 'env-repaired-inflight' + const responses: ((response: RuntimeRpcResponse) => void)[] = [] + const call = vi.fn( + () => + new Promise>((resolve) => { + responses.push(resolve) + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 11 }]) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 11 }) + replaceRuntimeEnvironmentRevisions([{ id: environmentId, createdAt: 1, pairingRevision: 12 }]) + settleEmptyInventory(environmentId, { expectedEnvironmentPairingRevision: 12 }) + expect(call).toHaveBeenCalledTimes(2) + + responses[0]!({ + id: 'probe-old-owner', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:old'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime-old' } + }) + responses[1]!({ + id: 'probe-new-owner', + ok: true, + result: { + terminals: [{ handle: 'terminal-new-owner' }], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['runtime:new'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime-new' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) + + it('invalidates a pending legacy probe when environment tracking is cleared', async () => { + const environmentId = 'env-cleared' + let resolveProbe!: (response: RuntimeRpcResponse) => void + const call = vi.fn( + () => + new Promise>((resolve) => { + resolveProbe = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(environmentId, WORKTREE, () => { + resumeSweeps += 1 + }) + + settleEmptyInventory(environmentId) + clearWebSessionTabsTrackingForEnvironment(environmentId) + resolveProbe({ + id: 'probe-cleared', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + }, + _meta: { runtimeId: 'runtime' } + }) + await flushProbe() + + expect(resumeSweeps).toBe(0) + expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(false) + }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts index dd852ec3367..db385639a41 100644 --- a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts +++ b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts @@ -4,6 +4,10 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-envi import { makePaneKey } from '../../../shared/stable-pane-id' import { resetStaleDocumentVisibilityForTesting } from '@/components/terminal-pane/stale-document-visibility' import { useAppStore, type AppState } from '@/store' +import { + clearRuntimeEnvironmentConnectionGenerationsForTests, + setRuntimeEnvironmentConnectionGenerationForTests +} from '@/store/slices/runtime-status' import { BG_MIRROR_TAB_ID, BG_WT, @@ -189,6 +193,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } = } resetWebSessionTabsSnapshotFreshnessForTests() resetHostSessionMirrorHydrationForTests() + setRuntimeEnvironmentConnectionGenerationForTests(ENV, 1) seedState() }) @@ -198,6 +203,7 @@ export function installFrameOrderingHarness(options: { fakeTimers?: boolean } = replaceRuntimeEnvironmentRevisions([]) resetWebSessionTabsSnapshotFreshnessForTests() resetHostSessionMirrorHydrationForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() if (options.fakeTimers) { resetStaleDocumentVisibilityForTesting() setDocumentVisibility('visible') diff --git a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx index 6c3b7388d63..3bb1355bfad 100644 --- a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx @@ -68,9 +68,11 @@ import { tabIds } from './host-session-mirror-frame-ordering-harness' import { + clearWebSessionTabsTrackingForEnvironment, useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' +import { hasHostSessionMirrorHydrated } from './host-session-mirror-hydration' import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking' /** @@ -212,6 +214,22 @@ describe('mirrored-pane resume deferral against real stream frames', () => { expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) expectReplayedResume(paneKey, WT, 'codex-session-scoped-frame') }) + + it('does not let a scoped frame from before tracking reset release its pane', async () => { + renderHook(() => useWebSessionTabsSync()) + await act(settle) + const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-scoped-old-tracking') + expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0) + + clearWebSessionTabsTrackingForEnvironment(ENV) + await publish(findSubscription('session.tabs.subscribe'), { + type: 'snapshot', + ...makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID) + }) + + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false) + }) }) describe('mirror latch verdicts against real stream failures', () => { diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index dcda5332160..75a88500462 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -104,11 +104,11 @@ describe('host-session-mirror settle census', () => { 'runtime/host-session-mirror-hydration.ts': { hydrated: 1, worktreeHydrated: 1 }, // Exactly the receipt constructors: the patch receipt (environment-wide // and per-worktree) and the stale-frame receipt. The environment-wide - // mark has two audited arms, both inside the receipt: an inventory that - // published snapshots settles on the spot, and one that published none + // mark has three audited arms, all inside the receipt: an authoritative + // empty inventory, a non-empty inventory, and an empty inventory that // settles only after `terminal.list` says the host has no live PTY — // `0 === 0` is not host evidence (STA-5377). - 'runtime/web-session-tabs-sync.ts': { hydrated: 2, worktreeHydrated: 2 } + 'runtime/web-session-tabs-sync.ts': { hydrated: 3, worktreeHydrated: 2 } }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx index 4f7050bf9e6..b07bce1052c 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx @@ -70,6 +70,7 @@ import { } from './host-session-mirror-hydration' import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session' import { + clearWebSessionTabsTrackingForEnvironment, useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' @@ -278,6 +279,36 @@ describe('the eager post-create list answers for its worktree', () => { expect(useAppStore.getState().tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBeUndefined() expect(hasHostSessionMirrorHydrated(ENV, FLOATING_TERMINAL_WORKTREE_ID)).toBe(false) }) + + it('does not let a pre-reset eager list settle in the new tracking epoch', async () => { + let resolveList!: (response: unknown) => void + runtimeCall.mockImplementation((request: { method: string }) => + request.method === 'session.tabs.list' + ? new Promise((resolve) => { + resolveList = resolve + }) + : new Promise(() => {}) + ) + const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-eager-old-tracking') + expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0) + + const refresh = refreshWebRuntimeSessionTabsSnapshot(ENV, WT) + await act(settle) + clearWebSessionTabsTrackingForEnvironment(ENV) + resolveList({ + id: 'list', + ok: true as const, + result: makeHostSnapshot(WT, OTHER_HOST_SURFACE_ID, OTHER_HOST_PARENT_TAB_ID), + _meta: { runtimeId: 'runtime-a' } + }) + await act(async () => { + await refresh + await settle() + }) + + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(false) + }) }) /** diff --git a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts index 760f025f57a..bded4a1eb67 100644 --- a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts +++ b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts @@ -24,6 +24,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), applyWebSessionTabsStorePatch: vi.fn(() => () => {}), resolveHostSessionTabIdForWebSessionTab: vi.fn() })) diff --git a/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts index 6e7b99757d4..1b5a2bea7b3 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts index ef959660c5d..505c22aafd4 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts index 9a0294b90e9..b920f9e62bd 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -48,6 +49,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts index fb4b0dfe22f..b348d73548a 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts index edc7aa06a80..2cfd7917cd8 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts index cd469bf4768..17c810c8060 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts index 92263598015..181f8b9a5fc 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -47,6 +48,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts index 74801336fd5..480012c4b96 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -50,6 +51,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) return () => {} diff --git a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts index 42dfa7612dc..9dbcf96d823 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts index f9e1fee9dd4..15ae9bfb1d8 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -37,6 +38,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts index 9585f87fd33..8d645c32174 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -36,6 +37,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 209cd182da9..394f67d9e77 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -49,6 +50,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts index 58c3b369712..6b1ba5e6d7c 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -42,6 +43,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts index d632e8b80fa..b08040c0e66 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ focusBrowserTabInWorktree: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -40,6 +41,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 76af63fbd63..675c9f34313 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), acceptReplayedWebSessionTabsSnapshot: vi.fn(), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), deliverLaunchPromptToAgentTab: vi.fn(), @@ -46,6 +47,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 1e443b13b25..c9313bbca7d 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -72,6 +72,7 @@ import { import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' import { translate } from '../i18n/i18n' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { parsePaneKey } from '../../../shared/stable-pane-id' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import { @@ -1074,8 +1075,13 @@ export async function refreshWebRuntimeSessionTabsSnapshot( errorMode?: 'warn' | 'throw' } = {} ): Promise { + const webSessionTabsSync = await import('./web-session-tabs-sync') const expectedEnvironmentPairingRevision = options.expectedEnvironmentPairingRevision ?? getRuntimeEnvironmentRevision(environmentId) + const expectedEnvironmentConnectionGeneration = + getRuntimeEnvironmentConnectionGeneration(environmentId) + const expectedTrackingGeneration = + webSessionTabsSync.getWebSessionTabsTrackingGeneration(environmentId) const callEnvironment = captureRuntimeEnvironmentCall( environmentId, expectedEnvironmentPairingRevision @@ -1124,7 +1130,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch, decideWebSessionTabsSnapshot - } = await import('./web-session-tabs-sync') + } = webSessionTabsSync if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) { return } @@ -1140,7 +1146,18 @@ export async function refreshWebRuntimeSessionTabsSnapshot( : state return patch === state ? state : patch }, - { frames: [{ environmentId, worktreeId: snapshot.worktree, decision }] }, + { + frames: [ + { + environmentId, + worktreeId: snapshot.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, snapshot ) settleMirror() diff --git a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx index 4d9ab464273..187fb213b3f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx @@ -49,6 +49,7 @@ import { useWebSessionTabsSync, WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync' +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT, WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS @@ -59,7 +60,7 @@ const ENV_B = 'env-b' const WORKTREE = 'repo-a::worktree-a' const REVISION_A = 101 const REVISION_B = 201 -const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00011\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00012\u0001${REVISION_B}` +const MIRROR_KEY = `${ENV_A}\u0001runtime-a\u00010\u0001${REVISION_A}\u0000${ENV_B}\u0001runtime-b\u00010\u0001${REVISION_B}` const initialState = useAppStore.getInitialState() type RuntimeSubscribe = typeof window.api.runtimeEnvironments.subscribe @@ -238,6 +239,7 @@ describe('useWebSessionTabsSync window visibility', () => { useAppStore.setState(initialState, true) replaceRuntimeEnvironmentRevisions([]) resetWebSessionTabsSnapshotFreshnessForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() resetStaleDocumentVisibilityForTesting() setDocumentVisibility('visible') vi.useRealTimers() @@ -627,7 +629,7 @@ describe('useWebSessionTabsSync window visibility', () => { vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) }) mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue( - MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013') + MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013') ) hook.rerender() await act(settle) @@ -795,7 +797,7 @@ describe('useWebSessionTabsSync window visibility', () => { expect(useAppStore.getState().browserTabsByWorktree[WORKTREE]).toBeUndefined() mocks.runtimeSessionMirrorEnvironmentKey.mockReturnValue( - MIRROR_KEY.replace('runtime-b\u00012', 'runtime-b\u00013') + MIRROR_KEY.replace('runtime-b\u00010', 'runtime-b\u00013') ) hook.rerender() await act(settle) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 994b3638476..073263be99a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -135,11 +135,16 @@ export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 type SessionTabsStreamEvent = | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | { + type: 'snapshots' + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean + } | { type: 'end' } type SessionTabsListAllResult = { snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean } type SnapshotFreshness = { @@ -181,6 +186,7 @@ const latestSessionTabsRemovalFenceByWorktree = new Map() const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() const sessionTabsEnvironmentsByWorktree = new Map>() +const sessionTabsTrackingGenerationByEnvironment = new Map() const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< @@ -811,6 +817,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) return } const keyPrefix = `${trimmedEnvironmentId}:` + sessionTabsTrackingGenerationByEnvironment.set( + trimmedEnvironmentId, + (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 + ) for (const key of latestSessionTabsSnapshotByWorktree.keys()) { if (key.startsWith(keyPrefix)) { latestSessionTabsSnapshotByWorktree.delete(key) @@ -862,6 +872,10 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) clearAllWebRuntimeWakeTerminalRespawn() } +export function getWebSessionTabsTrackingGeneration(environmentId: string): number { + return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 +} + function hostSessionTabMappingKey(args: { environmentId: string worktreeId: string @@ -3976,6 +3990,9 @@ export type HostSessionMirrorPatchFrame = { environmentId: string worktreeId: string decision: WebSessionTabsSnapshotDecision + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number } /** @@ -3990,7 +4007,14 @@ export type HostSessionMirrorPatchFrame = { */ export type HostSessionMirrorPatchVerdict = { frames: readonly HostSessionMirrorPatchFrame[] - fullInventory?: { environmentId: string; publishedSnapshotCount: number } + fullInventory?: { + environmentId: string + publishedSnapshotCount: number + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } } /** @@ -4005,16 +4029,52 @@ export type HostSessionMirrorPatchVerdict = { * `none` settles; `live` and `unverifiable` leave waiters for the next * inventory or per-worktree frame. */ -function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string): void { - const probedGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) - void probeHostLiveTerminals(environmentId, undefined, probedGeneration).then((verdict) => { +type HostSessionMirrorSettleFence = { + environmentId: string + connectionGeneration: number + pairingRevision?: number + trackingGeneration: number +} + +function captureHostSessionMirrorSettleFence( + environmentId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} +): HostSessionMirrorSettleFence { + return { + environmentId, + connectionGeneration: + expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId), + pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId), + trackingGeneration: + expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId) + } +} + +function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean { + return ( + getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration && + getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision && + getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration + ) +} + +function settleEmptyHostInventoryOnlyIfHostHasNoTerminals( + fence: HostSessionMirrorSettleFence +): void { + void probeHostLiveTerminals( + fence.environmentId, + undefined, + fence.connectionGeneration, + fence.pairingRevision + ).then((verdict) => { // Why: the probe is a round trip, and a reconnect in between would make its // answer speak for a connection whose PTYs nobody listed. - if ( - verdict === 'none' && - getRuntimeEnvironmentConnectionGeneration(environmentId) === probedGeneration - ) { - markHostSessionMirrorHydrated(environmentId) + if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorHydrated(fence.environmentId) } }) } @@ -4022,19 +4082,51 @@ function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(environmentId: string) function createHostSessionMirrorSettle( verdict: HostSessionMirrorPatchVerdict ): HostSessionMirrorSettle { + const fenceByEnvironment = new Map() + for (const frame of verdict.frames) { + fenceByEnvironment.set( + frame.environmentId, + captureHostSessionMirrorSettleFence(frame.environmentId, { + connectionGeneration: frame.expectedEnvironmentConnectionGeneration, + pairingRevision: frame.expectedEnvironmentPairingRevision, + trackingGeneration: frame.expectedTrackingGeneration + }) + ) + } + if (verdict.fullInventory) { + fenceByEnvironment.set( + verdict.fullInventory.environmentId, + captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, { + connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration, + pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision, + trackingGeneration: verdict.fullInventory.expectedTrackingGeneration + }) + ) + } return () => { const { frames, fullInventory } = verdict const settles = frames.filter(({ decision }) => decision.settlesHostMirror) if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) { + const fence = fenceByEnvironment.get(fullInventory.environmentId) + if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) { + return + } if (fullInventory.publishedSnapshotCount === 0) { - settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fullInventory.environmentId) + if (fullInventory.authoritative) { + markHostSessionMirrorHydrated(fullInventory.environmentId) + } else { + settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence) + } return } markHostSessionMirrorHydrated(fullInventory.environmentId) return } for (const { environmentId, worktreeId } of settles) { - markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + const fence = fenceByEnvironment.get(environmentId) + if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } } } } @@ -4048,11 +4140,22 @@ function createHostSessionMirrorSettle( function hostSessionMirrorSettleForPatchlessFrame( decision: WebSessionTabsSnapshotDecision, environmentId: string, - worktreeId: string + worktreeId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} ): HostSessionMirrorSettle | null { - return decision.settlesHostMirror - ? () => markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) - : null + if (!decision.settlesHostMirror) { + return null + } + const fence = captureHostSessionMirrorSettleFence(environmentId, expected) + return () => { + if (hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } + } } export function applyWebSessionTabsStorePatch( @@ -4217,7 +4320,9 @@ export function applyWebSessionTabsStorePatch( function loadInitialWebSessionTabs( environmentId: string, + expectedEnvironmentConnectionGeneration: number, expectedEnvironmentPairingRevision: number | undefined, + expectedTrackingGeneration: number, isCurrent: () => boolean ): void { // Why: only a conclusion that reached the store may settle the mirror, so @@ -4294,10 +4399,17 @@ function loadInitialWebSessionTabs( frames: applicable.map((snapshot, position) => ({ environmentId, worktreeId: snapshot.worktree, - decision: decisions[position]! + decision: decisions[position]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration })), fullInventory: { environmentId, + authoritative: result.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, // Why: a workspace the mirror never writes is not part of the // inventory the environment-wide verdict has to account for. publishedSnapshotCount: result.snapshots.filter((snapshot) => @@ -4406,9 +4518,11 @@ export function useWebSessionTabsSync(): void { ? runtimeSessionMirrorEnvironmentKey .split('\u0000') .map((entry) => { - const [environmentId = '', , , rawRevision = ''] = entry.split('\u0001') + const [environmentId = '', , rawConnectionGeneration = '0', rawRevision = ''] = + entry.split('\u0001') return { environmentId, + expectedEnvironmentConnectionGeneration: Number(rawConnectionGeneration), expectedEnvironmentPairingRevision: rawRevision === '' ? undefined : Number(rawRevision) } @@ -4453,6 +4567,9 @@ export function useWebSessionTabsSync(): void { inventoryReceived: boolean latestInventoryReceivedFrame: number pendingMissingWorktrees: Set + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number } type VisibilityResumeMissing = { environmentId: string @@ -4630,7 +4747,13 @@ export function useWebSessionTabsSync(): void { frames: decided.map(({ environmentId, snapshot, decision }) => ({ environmentId, worktreeId: snapshot.worktree, - decision + decision, + expectedEnvironmentConnectionGeneration: + batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision: + batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: + batch.environments.get(environmentId)?.expectedTrackingGeneration })) }, operations.map(({ snapshot }) => snapshot) @@ -4800,7 +4923,15 @@ export function useWebSessionTabsSync(): void { trackedWorktrees, inventoryReceived: false, latestInventoryReceivedFrame: 0, - pendingMissingWorktrees: new Set() + pendingMissingWorktrees: new Set(), + expectedEnvironmentConnectionGeneration: + environments.find((environment) => environment.environmentId === environmentId) + ?.expectedEnvironmentConnectionGeneration ?? + getRuntimeEnvironmentConnectionGeneration(environmentId), + expectedEnvironmentPairingRevision: environments.find( + (environment) => environment.environmentId === environmentId + )?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId) }) } } @@ -4819,7 +4950,11 @@ export function useWebSessionTabsSync(): void { } // Why: the stream's initial snapshot can land after first render, so a one-shot fetch makes initial parity deterministic. - for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { + for (const { + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision + } of environments) { if ( !shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: environmentId, @@ -4829,6 +4964,7 @@ export function useWebSessionTabsSync(): void { continue } let requestedInitialLoad = false + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) environmentIdBySubscriptionSpec.push(environmentId) subscriptionSpecs.push({ subscribe: (isCurrent, { visibilityGeneration }) => { @@ -4836,7 +4972,13 @@ export function useWebSessionTabsSync(): void { let awaitingVisibilityResumeInventory = isVisibilityRestart if (!requestedInitialLoad) { requestedInitialLoad = true - loadInitialWebSessionTabs(environmentId, expectedEnvironmentPairingRevision, isCurrent) + loadInitialWebSessionTabs( + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + isCurrent + ) } return window.api.runtimeEnvironments.subscribe( { @@ -4957,10 +5099,17 @@ export function useWebSessionTabsSync(): void { frames: applicable.map(({ snapshot }, position) => ({ environmentId, worktreeId: snapshot.worktree, - decision: decisions[position]! + decision: decisions[position]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration })), fullInventory: { environmentId, + authoritative: event.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, // Why: a workspace the mirror never writes is not // part of the inventory this verdict accounts for. publishedSnapshotCount: event.snapshots.filter((snapshot) => @@ -5049,7 +5198,16 @@ export function useWebSessionTabsSync(): void { settleHydration = applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), { - frames: [{ environmentId, worktreeId: recovered.worktree, decision }] + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] }, recovered, event.type === 'updated' && !replayed @@ -5059,7 +5217,12 @@ export function useWebSessionTabsSync(): void { settleHydration = hostSessionMirrorSettleForPatchlessFrame( decision, environmentId, - recovered.worktree + recovered.worktree, + { + connectionGeneration: expectedEnvironmentConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + } ) } } @@ -5137,6 +5300,7 @@ export function useWebSessionTabsSync(): void { ) { return } + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) let requestedInitialTerminal = false let requestedRespawnAfterWake = false @@ -5146,7 +5310,8 @@ export function useWebSessionTabsSync(): void { event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, response: RuntimeRpcResponse, isCurrent: () => boolean, - receivedFrame: number + receivedFrame: number, + expectedTrackingGeneration: number ): Promise => { const recovered = await recoverWebSessionTerminalOrphansBeforeApply( useAppStore.getState(), @@ -5193,13 +5358,29 @@ export function useWebSessionTabsSync(): void { // Why: a rejected frame settles only on the accepted view that outranked it. let settleMirror: HostSessionMirrorSettle | null = fresh ? null - : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree) + : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, { + connectionGeneration: activeWorktreeRuntimeConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + }) try { if (fresh) { const replayed = isRuntimeSubscriptionReplayResponse(response) settleMirror = applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - { frames: [{ environmentId, worktreeId: recovered.worktree, decision }] }, + { + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration: + activeWorktreeRuntimeConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, recovered, event.type === 'updated' && !replayed ) @@ -5278,7 +5459,13 @@ export function useWebSessionTabsSync(): void { event.worktree, receivedFrame ) - void applyActiveSnapshot(event, response, isCurrent, receivedFrame) + void applyActiveSnapshot( + event, + response, + isCurrent, + receivedFrame, + expectedTrackingGeneration + ) .catch((error) => { if (isCurrent()) { console.warn( diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 0e2d300bef3..a3f860f1ea1 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -84,6 +84,13 @@ export function getRuntimeEnvironmentConnectionGeneration(environmentId: string) export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => connectionGenerationByEnvironment.clear() +export const setRuntimeEnvironmentConnectionGenerationForTests = ( + environmentId: string, + generation: number +): void => { + connectionGenerationByEnvironment.set(environmentId, generation) +} + function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 connectionGenerationByEnvironment.set(environmentId, next) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 0c4e45b5bcd..bfd58f181e1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -106,6 +106,8 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const +export const SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY = + 'session-tabs.authoritative-inventory.v1' as const export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' @@ -189,6 +191,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 5efaf0aed9d..b705e279b33 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability @@ -15,6 +16,7 @@ export function remoteRuntimeClientCapabilities( return Array.from( new Set([ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts index 1fbe42bddb2..9855d8d92b0 100644 --- a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -408,6 +408,15 @@ test('foregrounds a preserved daemon PTY after the paired host relaunches', asyn .toBe(true) await expectTerminalInteractive(client, target, 'x') target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId) + const restored = await callRuntime<{ terminal: { ptyId: string | null } }>( + client.page, + client.environmentId, + 'terminal.show', + { terminal: target.handle } + ) + expect(restored.terminal.ptyId, 'host inventory must preserve the daemon PTY identity').toBe( + target.ptyId + ) const reconnectControl = await createHostTerminal(client, worktreeId, 'reconnect-control') terminals.push(reconnectControl) diff --git a/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts new file mode 100644 index 00000000000..c630d0420fc --- /dev/null +++ b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from '../../src/main/daemon/session-subprocess-handle' +import { TerminalHost } from '../../src/main/daemon/terminal-host' + +vi.mock('../../src/renderer/src/store', () => ({ + useAppStore: { + setState: vi.fn(), + getState: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}) + } +})) + +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '../../src/renderer/src/store/slices/runtime-status' +import { + hasHostSessionMirrorHydrated, + parkUntilHostSessionMirrorHydrates, + resetHostSessionMirrorHydrationForTests +} from '../../src/renderer/src/runtime/host-session-mirror-hydration' +import { clearHostLiveTerminalProbesForTests } from '../../src/renderer/src/runtime/host-live-terminal-probe' +import { replaceRuntimeEnvironmentRevisions } from '../../src/renderer/src/runtime/runtime-environment-revision' +import { applyWebSessionTabsStorePatch } from '../../src/renderer/src/runtime/web-session-tabs-sync' + +const ENVIRONMENT_ID = 'env-live-unpublished' +const WORKTREE = 'repo1::/path/wt1' + +type WriterSubprocess = SubprocessHandle & { + write: ReturnType void>> + exit: () => void +} + +function createWriterSubprocess(pid: number): WriterSubprocess { + let onExit: ((code: number) => void) | null = null + const write = vi.fn<(data: string) => void>() + return { + pid, + getForegroundProcess: () => 'codex', + write, + resize: vi.fn(), + kill: vi.fn(), + terminateOwnedTree: () => 'unavailable', + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit: (listener) => { + onExit = listener + }, + dispose: vi.fn(), + exit: () => onExit?.(0) + } +} + +describe('unpublished empty inventory daemon oracle', () => { + beforeEach(() => { + resetHostSessionMirrorHydrationForTests() + clearRuntimeEnvironmentConnectionGenerationsForTests() + clearHostLiveTerminalProbesForTests() + replaceRuntimeEnvironmentRevisions([]) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('keeps the resume dispatch parked and exactly one daemon writer live', async () => { + const subprocesses: ReturnType[] = [] + const host = new TerminalHost({ + spawnSubprocess: () => { + const subprocess = createWriterSubprocess(90_000 + subprocesses.length) + subprocesses.push(subprocess) + return subprocess + } + }) + await host.createOrAttach({ + sessionId: 'original-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const call = vi.fn(async () => ({ + ok: true, + result: { + terminals: host.listSessions().map(({ sessionId }) => ({ + handle: sessionId, + worktreeId: WORKTREE, + connected: true + })), + totalCount: host.listSessions().length, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + let replacement: Promise | null = null + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + replacement = host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { environmentId: ENVIRONMENT_ID, publishedSnapshotCount: 0 } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + await (replacement ?? Promise.resolve()) + + const liveSessionIds = host.listSessions().map(({ sessionId }) => sessionId) + for (const sessionId of liveSessionIds) { + host.write(sessionId, `writer:${sessionId}`) + } + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + liveSessionIds, + subprocessPids: subprocesses.map(({ pid }) => pid), + writerCalls: subprocesses.map(({ write }) => write.mock.calls) + }).toEqual({ + resumeSweeps: 0, + mirrorHydrated: false, + liveSessionIds: ['original-live-session'], + subprocessPids: [90_000], + writerCalls: [[['writer:original-live-session']]] + }) + expect(call).toHaveBeenCalledOnce() + for (const subprocess of subprocesses) { + subprocess.exit() + } + await host.dispose() + }) + + it('settles an authoritative empty inventory and releases the dispatch without a probe', async () => { + const call = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { + environmentId: ENVIRONMENT_ID, + publishedSnapshotCount: 0, + authoritative: true + } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + probeCalls: call.mock.calls.length + }).toEqual({ resumeSweeps: 1, mirrorHydrated: true, probeCalls: 0 }) + }) + + it('records legacy unconditional empty hydration releasing the resume dispatch', async () => { + const subprocesses: ReturnType[] = [] + const host = new TerminalHost({ + spawnSubprocess: () => { + const subprocess = createWriterSubprocess(90_000 + subprocesses.length) + subprocesses.push(subprocess) + return subprocess + } + }) + await host.createOrAttach({ + sessionId: 'original-live-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + // Legacy hosts answer the liveness probe with a scoped zero-terminal census. + const call = vi.fn(async () => ({ + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: ['runtime:env'], omittedHostIds: [] } + } + })) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } }) + let resumeSweeps = 0 + let replacement: Promise | null = null + parkUntilHostSessionMirrorHydrates(ENVIRONMENT_ID, WORKTREE, () => { + resumeSweeps += 1 + replacement = host.createOrAttach({ + sessionId: 'replacement-resume-session', + cols: 80, + rows: 24, + launchAgent: 'codex', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + }) + + applyWebSessionTabsStorePatch(() => ({}), { + frames: [], + fullInventory: { environmentId: ENVIRONMENT_ID, publishedSnapshotCount: 0 } + })() + await new Promise((resolve) => setTimeout(resolve, 0)) + await (replacement ?? Promise.resolve()) + + const liveSessionIds = host.listSessions().map(({ sessionId }) => sessionId) + for (const sessionId of liveSessionIds) { + host.write(sessionId, `writer:${sessionId}`) + } + + expect({ + resumeSweeps, + mirrorHydrated: hasHostSessionMirrorHydrated(ENVIRONMENT_ID, WORKTREE), + liveSessionIds, + subprocessPids: subprocesses.map(({ pid }) => pid), + writerCalls: subprocesses.map(({ write }) => write.mock.calls) + }).toEqual({ + resumeSweeps: 1, + mirrorHydrated: true, + liveSessionIds: ['original-live-session', 'replacement-resume-session'], + subprocessPids: [90_000, 90_001], + writerCalls: [[['writer:original-live-session']], [['writer:replacement-resume-session']]] + }) + expect(call).toHaveBeenCalledOnce() + for (const subprocess of subprocesses) { + subprocess.exit() + } + await host.dispose() + }) +}) From 3d8b9eeeb02e4cc7366c198ec7acadfaa8d452b2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:31:39 -0700 Subject: [PATCH 2/9] fix(crash-reporting): stop discarding the whole module list on POSIX dumps (#16687) --- .../minidump-crash-signature.test.ts | 47 +++++++++++++++++++ .../minidump-crash-signature.ts | 7 ++- .../crash-reporting/minidump-stream-reader.ts | 2 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/main/crash-reporting/minidump-crash-signature.test.ts b/src/main/crash-reporting/minidump-crash-signature.test.ts index 2d1fbb90972..8e878cf3d23 100644 --- a/src/main/crash-reporting/minidump-crash-signature.test.ts +++ b/src/main/crash-reporting/minidump-crash-signature.test.ts @@ -194,6 +194,22 @@ function buildDump(options: { return { dump: builder.build(streams) } } +/** Offset of a stream's directory entry: `{type, size, rva}`. */ +function streamEntry(dump: Buffer, type: number): number { + const directoryRva = dump.readUInt32LE(12) + for (let index = 0; index < dump.readUInt32LE(8); index += 1) { + const entry = directoryRva + index * 12 + if (dump.readUInt32LE(entry) === type) { + return entry + } + } + throw new Error(`no stream of type ${type}`) +} + +function moduleListRva(dump: Buffer): number { + return dump.readUInt32LE(streamEntry(dump, STREAM_TYPE_MODULE_LIST) + 8) +} + const FATAL_LINE = '[8104:1234:0815/143022.123456:FATAL:render_frame_impl.cc(4821)] Check failed: !is_detached_.' @@ -340,6 +356,37 @@ describe('parseMinidumpCrashSignature', () => { expect(signature?.faultingModuleOffset).toBe('0x1234') }) + it('resolves a faulting module past index 1024 on a real macOS image count', () => { + // A measured macOS renderer carries 1042 loaded images; a cap below that + // dropped the whole module list, so no macOS report could name a module. + const modules = Array.from({ length: 1042 }, (_, index) => ({ + base: 0x1_0000_0000n + BigInt(index) * 0x1_0000n, + size: 0x1000, + name: `/Applications/Orca.app/Contents/Frameworks/lib${index}.dylib` + })) + const { dump } = buildDump({ + exception: { code: 11, address: 0x1_0000_0000n + 1030n * 0x1_0000n + 0x24n }, + modules + }) + + const signature = parseMinidumpCrashSignature(dump) + + expect(signature?.faultingModule).toBe('lib1030.dylib') + expect(signature?.faultingModuleOffset).toBe('0x24') + }) + + it('still drops the module list when the claimed module count is absurd', () => { + const { dump } = buildDump({ + exception: { code: 11, address: 0x7ff7_0000_0010n }, + modules: [{ base: 0x7ff7_0000_0000n, size: 0x1000, name: '/opt/orca/orca' }] + }) + const corrupt = Buffer.from(dump) + corrupt.writeUInt32LE(0xffff_ffff, moduleListRva(corrupt)) + + expect(() => parseMinidumpCrashSignature(corrupt)).not.toThrow() + expect(parseMinidumpCrashSignature(corrupt)?.faultingModule).toBeUndefined() + }) + it('omits the faulting module when no image range covers the address', () => { const { dump } = buildDump({ exception: { code: 11, address: 0x10n }, diff --git a/src/main/crash-reporting/minidump-crash-signature.ts b/src/main/crash-reporting/minidump-crash-signature.ts index 3e246d4b77a..fe4e930504d 100644 --- a/src/main/crash-reporting/minidump-crash-signature.ts +++ b/src/main/crash-reporting/minidump-crash-signature.ts @@ -11,13 +11,16 @@ // rather than throwing: a truncated dump must degrade, not break crash // reporting. -import { findStream, isMinidump, MAX_MODULES, MinidumpView } from './minidump-stream-reader' +import { findStream, isMinidump, MinidumpView } from './minidump-stream-reader' import { readCrashpadAnnotations } from './minidump-crashpad-annotations' const STREAM_TYPE_MODULE_LIST = 4 const STREAM_TYPE_EXCEPTION = 6 const MODULE_RECORD_SIZE = 108 +// 8x headroom over a measured 1042-image macOS renderer, whose whole list a +// 1_024 cap dropped; a dump claiming more than this is corrupt. +const MAX_MODULE_LIST_MODULES = 8_192 const MODULE_BASE_OFFSET = 0 const MODULE_SIZE_OFFSET = 8 const MODULE_NAME_RVA_OFFSET = 20 @@ -70,7 +73,7 @@ function readModules(view: MinidumpView): ModuleRecord[] { return [] } const count = view.u32(stream.rva) - if (count === null || count > MAX_MODULES) { + if (count === null || count > MAX_MODULE_LIST_MODULES) { return [] } const modules: ModuleRecord[] = [] diff --git a/src/main/crash-reporting/minidump-stream-reader.ts b/src/main/crash-reporting/minidump-stream-reader.ts index 1230a6f9c48..4b9f32a07a3 100644 --- a/src/main/crash-reporting/minidump-stream-reader.ts +++ b/src/main/crash-reporting/minidump-stream-reader.ts @@ -13,7 +13,7 @@ const DIRECTORY_ENTRY_SIZE = 12 // A dump claiming an absurd stream count is corrupt; cap before iterating. const MAX_STREAMS = 4_096 export const MAX_ANNOTATION_VALUE_BYTES = 8_192 -// Shared cap: both the MINIDUMP_MODULE_LIST and Crashpad's per-module info list. +// Cap for Crashpad's per-module info list; the MINIDUMP_MODULE_LIST is capped separately. export const MAX_MODULES = 1_024 export type LocationDescriptor = { From 762fb05caa6e252e34768ca80d9d41946c41e1cf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:31:57 -0700 Subject: [PATCH 3/9] fix(agents): one-shot submit-retry Enter for codex (STA-5379) (#16689) Codex silently discards Enter for ~75-150ms after its composer glyph first renders, and the boundary widens with prompt size and machine load, so no fixed first-Enter delay is provably safe on slow hosts. Submit success is not verifiable from PTY output, but a redundant Enter is a measured no-op on codex in both post-submit states, so send one blind retry after the first Enter. - tui-agent-config: new optional submitRetryDelayMs knob, set to 1200 on codex only; every other agent is byte-identical to today. - agent-paste-draft: after the post-paste '\r', wait the configured gap and send exactly one more '\r' inside the same PTY input transaction, so a concurrent paste cannot interleave. The retry is best-effort and never downgrades the first Enter's result. - Retry tests live in a new file to keep agent-paste-draft.test.ts under the max-lines budget. active-agent-note-send is deliberately exempt: its Enter rides the terminal.send RPC (different transport, server-side sendable guard), has no local agent identity to read the config from, and only fires on an already-running agent, where the codex cold-boot submit gate cannot occur. --- .../agent-paste-draft-submit-retry.test.ts | 197 ++++++++++++++++++ .../src/lib/agent-paste-draft.test.ts | 4 +- src/renderer/src/lib/agent-paste-draft.ts | 29 ++- src/shared/tui-agent-config.ts | 5 +- 4 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts new file mode 100644 index 00000000000..5b763aa5c6d --- /dev/null +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, + pasteDraftWhenAgentReady, + POST_PASTE_SUBMIT_DELAY_MS, + sendAgentDraftPasteContent +} from './agent-paste-draft' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +const testState = vi.hoisted(() => ({ + appState: { + settings: {} as Record, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + runtimePaneTitlesByTabId: {}, + tabsByWorktree: {} as Record, + repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[], + worktreesByRepo: {} as Record + }, + ptyObserver: null as ((data: string) => void) | null, + unsubscribe: vi.fn(), + subscribeToPtyData: vi.fn(), + replayPreHandlerPtyData: vi.fn(), + isRemoteRuntimePtyId: vi.fn(), + sendRuntimePtyInputVerified: vi.fn(), + inspectRuntimeTerminalProcess: vi.fn(), + subscribeToRuntimeTerminalData: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => testState.appState, + subscribe: () => () => {} + } +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: testState.subscribeToPtyData +})) + +vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ + replayPreHandlerPtyData: testState.replayPreHandlerPtyData +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, + sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, + inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess +})) + +vi.mock('@/runtime/runtime-terminal-stream', () => ({ + subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData +})) + +const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const RENDER_QUIET_MS = 1500 +const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' +const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~` +const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0 + +describe('post-paste submit retry Enter', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout + }) + testState.appState.settings = {} + testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + testState.appState.tabsByWorktree = {} + testState.appState.repos = [] + testState.appState.worktreesByRepo = {} + testState.ptyObserver = null + testState.unsubscribe.mockReset() + testState.subscribeToPtyData.mockReset() + testState.subscribeToPtyData.mockImplementation( + (_ptyId: string, observer: (data: string) => void) => { + testState.ptyObserver = observer + return testState.unsubscribe + } + ) + testState.replayPreHandlerPtyData.mockReset() + testState.isRemoteRuntimePtyId.mockReset() + testState.isRemoteRuntimePtyId.mockReturnValue(false) + testState.sendRuntimePtyInputVerified.mockReset() + testState.sendRuntimePtyInputVerified.mockResolvedValue(true) + testState.inspectRuntimeTerminalProcess.mockReset() + testState.subscribeToRuntimeTerminalData.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('sends one retry Enter after the configured gap for agents that can eat the first Enter', async () => { + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + + expect(enterWrites()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS - 1) + expect(enterWrites()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(2) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') + expect(vi.getTimerCount()).toBe(0) + }) + + it('sends exactly one Enter for agents without a submit retry delay', async () => { + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'gemini', + submit: true + }) + await flushMicrotasks() + testState.ptyObserver?.(DECSET_BRACKETED_PASTE) + await vi.advanceTimersByTimeAsync(RENDER_QUIET_MS) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('holds the PTY input transaction across the retry Enter', async () => { + const writes: string[] = [] + testState.sendRuntimePtyInputVerified.mockImplementation( + async (_settings: unknown, _ptyId: string, data: string) => { + writes.push(data) + return true + } + ) + + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + // Competing paste on the same PTY: it must not open a frame the retry can land in. + const competing = sendAgentDraftPasteContent( + {}, + 'pty-1', + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + ) + await flushMicrotasks(10) + expect(writes).toEqual([PASTED_ISSUE_URL, '\r']) + + await vi.advanceTimersByTimeAsync(CODEX_SUBMIT_RETRY_DELAY_MS) + await expect(promise).resolves.toBe(true) + await expect(competing).resolves.toBe(true) + + expect(writes.slice(0, 3)).toEqual([PASTED_ISSUE_URL, '\r', '\r']) + expect(writes.at(3)).toBe('\x1b[200~') + }) + + it('keeps a successful submit successful when the retry Enter is rejected', async () => { + testState.sendRuntimePtyInputVerified + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('terminal_not_writable')) + + const promise = startCodexSubmit() + await signalCodexComposerReady() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(2) + }) +}) + +function enterWrites(): unknown[][] { + return testState.sendRuntimePtyInputVerified.mock.calls.filter((call) => call[2] === '\r') +} + +function startCodexSubmit(): Promise { + return pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex', + submit: true + }) +} + +async function signalCodexComposerReady(): Promise { + await flushMicrotasks() + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}`) + await flushMicrotasks() +} + +async function flushMicrotasks(iterations = 2): Promise { + for (let index = 0; index < iterations; index += 1) { + await Promise.resolve() + } +} diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index a09c88901c4..c8a660f2576 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -13,6 +13,7 @@ import { sendBracketedPasteToRunningAgent, submitPromptToAgentPty } from './agent-paste-draft' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' const testState = vi.hoisted(() => ({ appState: { @@ -70,6 +71,7 @@ const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything const CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~` +const CODEX_SUBMIT_RETRY_DELAY_MS = TUI_AGENT_CONFIG.codex.submitRetryDelayMs ?? 0 describe('pasteDraftWhenAgentReady', () => { beforeEach(() => { @@ -169,7 +171,7 @@ describe('pasteDraftWhenAgentReady', () => { 'pty-1', PASTED_ISSUE_URL ) - await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS + CODEX_SUBMIT_RETRY_DELAY_MS) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') expect(testState.unsubscribe).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index f078383333d..72e467ee59e 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -129,7 +129,8 @@ export async function pasteDraftWhenAgentReady(args: { settings, ptyId, content, - submit: submit === true + submit: submit === true, + agent }) } @@ -168,7 +169,8 @@ export async function pasteDraftToAgentPtyWhenReady(args: { settings, ptyId, content, - submit: submit === true + submit: submit === true, + agent }) } @@ -197,11 +199,13 @@ async function sendBracketedPasteToAgent(args: { ptyId: string content: string submit: boolean + agent?: TuiAgent }): Promise { - const { settings = useAppStore.getState().settings, ptyId, content, submit } = args + const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args + const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined try { - // Why: paste + Enter must be one transaction, or a concurrent paste on this PTY - // can slip between them and submit a half-written prompt. + // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent + // paste on this PTY can slip between them and submit a half-written prompt. return await runTerminalPtyInputTransaction(ptyId, async () => { const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content) if (!pasted || !submit) { @@ -212,7 +216,20 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - return await sendRuntimePtyInputVerified(settings, ptyId, '\r') + const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r') + + if (submitRetryDelayMs !== undefined) { + // Why: agents that render their composer before Enter is live silently eat + // the first Enter; the retry is best-effort and never downgrades `submitted`. + await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) + try { + await sendRuntimePtyInputVerified(settings, ptyId, '\r') + } catch { + // Why: a rejected retry leaves the first Enter's verdict untouched. + } + } + + return submitted }) } catch { return false diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index de3c79a68b1..4a89e1cb13b 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -42,6 +42,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ + submitRetryDelayMs?: number /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ windowsShiftEnterEncoding?: 'csi-u' /** Paste newlines for TUIs that read Windows console input records instead of VT paste frames. */ @@ -90,7 +92,8 @@ export const TUI_AGENT_CONFIG: Record = { windowsInputRecordPasteNewline: 'alt-enter', preflightTrust: 'codex', draftPasteReadySignal: 'codex-composer-prompt', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + submitRetryDelayMs: 1200 }, autohand: { detectCmd: 'autohand', From 6e28c9a9b7183844ebe1dd4872040a0c0d305b18 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:33:01 -0700 Subject: [PATCH 4/9] fix(editor): constrain image previews before load instead of laying out at natural size (#16686) --- .../components/editor/ImageViewer.test.tsx | 23 +++++++++++++++++++ .../src/components/editor/ImageViewer.tsx | 4 +++- .../components/editor/ImageViewerPopup.tsx | 6 ++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/editor/ImageViewer.test.tsx b/src/renderer/src/components/editor/ImageViewer.test.tsx index cdd067ae0e3..8845cf33bd5 100644 --- a/src/renderer/src/components/editor/ImageViewer.test.tsx +++ b/src/renderer/src/components/editor/ImageViewer.test.tsx @@ -239,3 +239,26 @@ describe('ImageViewer preview source retry', () => { expect(findElementsByType(rendered, 'img')).toHaveLength(0) }) }) + +describe('ImageViewer pre-load layout box', () => { + beforeEach(() => { + reactHookRuntime.states = [] + reactHookRuntime.index = 0 + vi.clearAllMocks() + }) + + // Why: the scroll surface's inner box is w-max/h-max, so percentage maxes resolve + // to none and a 6000x4000 image lays out at natural resolution before onLoad. + it('bounds both scroll surfaces with viewport units before the image loads', async () => { + const images = findElementsByType(await renderExpandedImageViewer(pngBase64(1)), 'img') + + expect(images).toHaveLength(2) + for (const image of images) { + const className = String(image.props.className) + expect(className).toContain('max-h-[100vh]') + expect(className).toContain('max-w-[100vw]') + expect(className).not.toContain('max-h-full') + expect(className).not.toContain('max-w-full') + } + }) +}) diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 81917eed2bb..b3e86bdfc81 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -291,7 +291,9 @@ export default function ImageViewer({ ? 'block h-auto max-h-none max-w-full' : inlineImageLayoutSize ? 'block h-full w-full' - : 'block max-h-full max-w-full' + : // Why: the w-max/h-max scroll box makes percentage maxes resolve to + // none, so only viewport units bound the image before onLoad. + 'block max-h-[100vh] max-w-[100vw]' )} onLoad={(event) => { const img = event.currentTarget diff --git a/src/renderer/src/components/editor/ImageViewerPopup.tsx b/src/renderer/src/components/editor/ImageViewerPopup.tsx index 6eb3ac99d58..4cce2b699a3 100644 --- a/src/renderer/src/components/editor/ImageViewerPopup.tsx +++ b/src/renderer/src/components/editor/ImageViewerPopup.tsx @@ -61,7 +61,11 @@ export default function ImageViewerPopup({ alt={filename} className={cn( 'object-contain', - imageLayoutSize ? 'block h-full w-full' : 'block max-h-full max-w-full' + imageLayoutSize + ? 'block h-full w-full' + : // Why: the w-max/h-max scroll box makes percentage maxes resolve to + // none, so only viewport units bound the image before onLoad. + 'block max-h-[100vh] max-w-[100vw]' )} /> From 9c01e09ecc9d3c1203968ace9945d16edfb35dd2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:42:37 -0700 Subject: [PATCH 5/9] Revert "fix(codex): launch WSL accounts from direct homes" and "refactor(codex): remove WSL runtime mirror machinery" (#16722) This reverts commit ebcd637db981b0ce272c8e183794a8fc7caaa7be (#16504) and dependent commit 673842db35b37557006d9f20d04e077780253d82 (#16505). Launch-blocker rationale (findings-counsel validated): - P0 Data Loss (F06-1): The WSL legacy auth drain deleteSource=1 path deletes intact source auth without re-validating the destination after concurrent destination rewrites, permanently corrupting auth credentials on upgrade. - P1 Workflow Regression (FC-01): Pre-upgrade sessions under ~/.local/share/orca/codex-runtime-home/home are not linked into direct homes, breaking /resume in the Codex CLI for upgrading WSL users. --- .../agent-auth-restart-preservation.test.ts | 126 ++--- src/main/agent-auth-restart-preservation.ts | 49 +- ...sl-runtime-auth-drain-apply-script.test.ts | 170 ------ .../legacy-wsl-runtime-auth-drain.test.ts | 212 ------- .../legacy-wsl-runtime-auth-drain.ts | 319 ----------- .../codex-accounts/runtime-home-service.ts | 529 ++++++++++++------ .../runtime-home-wsl-managed-accounts.test.ts | 239 +++++++- .../runtime-home-wsl-session-bridge.test.ts | 128 ++++- .../runtime-home-wsl-system-default.test.ts | 47 +- .../wsl-codex-auth-batch-reader.ts | 70 --- src/main/codex/codex-pane-account-registry.ts | 9 - .../codex/codex-pane-launch-account.test.ts | 2 +- src/main/codex/codex-pane-launch-account.ts | 17 +- .../codex/codex-stale-pane-accounts.test.ts | 31 - src/main/pty/codex-home-wsl-env.ts | 4 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 + .../child-process-import-allowlist.txt | 1 + 17 files changed, 788 insertions(+), 1166 deletions(-) delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts delete mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts delete mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index b94bcc2435d..441ec609624 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -15,7 +15,8 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex') - }) + }), + syncActiveWslSelectionsBeforeRestart: vi.fn() }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -32,13 +33,35 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['codex', 'claude', 'flush']) }) - it('runs Claude preservation after Codex and before the store flush', async () => { + it('runs WSL Codex preservation through the runtime service', async () => { + const syncForCurrentSelection = vi.fn() + const syncActiveWslSelectionsBeforeRestart = vi.fn() + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection, + syncActiveWslSelectionsBeforeRestart + }, + store: { + flushPendingOrThrowAsync: vi.fn() + } + }) + + expect(syncForCurrentSelection).toHaveBeenCalledTimes(1) + expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1) + expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1) + }) + + it('runs Claude preservation before WSL Codex preservation', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex-host') + }), + syncActiveWslSelectionsBeforeRestart: vi.fn(() => { + calls.push('codex-wsl') }) }, claudeRuntimeAuth: { @@ -53,80 +76,26 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex-host', 'claude', 'flush']) + expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush']) }) - it('drains retained WSL Codex auth before flushing the store', async () => { - const calls: string[] = [] - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(() => { - calls.push('codex-host') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { - calls.push('codex-wsl') - }) - }, - store: { - flushPendingOrThrowAsync: vi.fn(async () => { - calls.push('flush') - }) - } - }) - - expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush']) - }) - - it('does not release restart while the bounded WSL drain is still running', async () => { - vi.useFakeTimers() - let finishWslDrain!: () => void - let settled = false - const flushPendingOrThrowAsync = vi.fn() - - const preservation = preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn( - () => - new Promise((resolve) => { - finishWslDrain = resolve - }) - ) - }, - store: { flushPendingOrThrowAsync } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.advanceTimersByTimeAsync(1) - expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(settled).toBe(false) - - finishWslDrain() - await preservation - expect(settled).toBe(true) - }) - - it('continues after the bounded WSL drain fails without logging secrets', async () => { + it('continues after WSL Codex preservation fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { + syncActiveWslSelectionsBeforeRestart: vi.fn(() => { throw new Error('wsl-token-secret') }) }, - store: { flushPendingOrThrowAsync } + store: { + flushPendingOrThrowAsync + } }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) - expect(warn).toHaveBeenCalledWith( - '[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update' - ) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) @@ -147,7 +116,8 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { throw new Error('codex-token-secret') - }) + }), + syncActiveWslSelectionsBeforeRestart: vi.fn() }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -200,36 +170,6 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) - it('gives store persistence its own timeout after auth preservation expires', async () => { - vi.useFakeTimers() - let finishStoreFlush!: () => void - let settled = false - - const preservation = preserveAgentAuthBeforeRestart({ - claudeRuntimeAuth: { - syncForCurrentSelection: vi.fn(() => new Promise(() => {})) - }, - store: { - flushPendingOrThrowAsync: vi.fn( - () => - new Promise((resolve) => { - finishStoreFlush = resolve - }) - ) - } - }).then(() => { - settled = true - }) - - await vi.advanceTimersByTimeAsync(2_000) - await vi.advanceTimersByTimeAsync(1) - expect(settled).toBe(false) - - finishStoreFlush() - await preservation - expect(settled).toBe(true) - }) - it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index e351bfee334..eaae45f331b 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -4,8 +4,10 @@ import type { Store } from './persistence' const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 -type CodexRuntimeAuthSync = Pick & - Partial> +type CodexRuntimeAuthSync = Pick< + CodexRuntimeHomeService, + 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' +> type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick @@ -25,26 +27,35 @@ export async function preserveAgentAuthBeforeRestart({ claudeRuntimeAuth, store }: AgentAuthRestartPreservationOptions): Promise { - runCodexPreservationStep(codexRuntimeHome) - // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. - const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) + const startedAt = Date.now() - if (claudeRuntimeAuth) { + runCodexPreservationStep(codexRuntimeHome) + + const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) + if (claudeRuntimeAuth && remainingMs > 0) { await runWithinLifecycleTimeout( 'Claude auth preservation', () => claudeRuntimeAuth.syncForCurrentSelection(), - AUTH_PRESERVATION_TIMEOUT_MS + remainingMs ) + } else if (claudeRuntimeAuth) { + logStepTimeout('Claude auth preservation', 0) } - const storePreservation = store - ? runWithinLifecycleTimeout( - 'Store persistence', - () => store.flushPendingOrThrowAsync(), - AUTH_PRESERVATION_TIMEOUT_MS - ) - : Promise.resolve() - await Promise.all([wslCodexPreservation, storePreservation]) + if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) { + runWslCodexPreservationStep(codexRuntimeHome) + } else if (codexRuntimeHome) { + logStepTimeout('Codex auth preservation', 0) + } + + if (store) { + const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) + await runWithinLifecycleTimeout( + 'Store persistence', + () => store.flushPendingOrThrowAsync(), + storeRemainingMs + ) + } } function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void { @@ -55,11 +66,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null } } -async function runWslCodexPreservationStep( +function runWslCodexPreservationStep( codexRuntimeHome: CodexRuntimeAuthSync | null | undefined -): Promise { +): void { try { - await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.() + codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart() } catch (error) { logStepFailure('Codex auth preservation', error) } @@ -78,7 +89,7 @@ async function runWithinLifecycleTimeout( }) // Why: this timeout only releases the restart/update path. It does not - // cancel a sync that already started. + // cancel a sync that already started, and Codex sync is synchronous today. const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts deleted file mode 100644 index 44ce6a6e00f..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { _internals } from './legacy-wsl-runtime-auth-drain' - -const isWindows = process.platform === 'win32' - -const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' -const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' -const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' -// Codex truncates before it writes, so a read landing mid-rotation sees this. -const TORN_AUTH = '{"tokens":{"exp' -const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' -const TORN_CREDENTIALS = '{"server":' - -function sha256(contents: string): string { - return createHash('sha256').update(contents).digest('hex') -} - -/** - * Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen - * hash call can rewrite the source underneath the script. That is the only way - * to land Codex's in-place rotation inside the window the script itself opens. - */ -function runApplyScript( - options: { - rewriteSourceAfterHashCall?: number - rewriteBytes?: string - rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' - sourceCredentials?: string - } = {} -): { - legacyAuth: string - status: number - targetAuth: string - targetCredentials: string | null -} { - const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-')) - const legacyHome = join(root, 'legacy') - const targetHome = join(root, 'account') - const binDir = join(root, 'bin') - for (const dir of [legacyHome, targetHome, binDir]) { - mkdirSync(dir, { recursive: true }) - } - const legacyAuthPath = join(legacyHome, 'auth.json') - const targetAuthPath = join(targetHome, 'auth.json') - const legacyCredentialsPath = join(legacyHome, '.credentials.json') - const targetCredentialsPath = join(targetHome, '.credentials.json') - writeFileSync(legacyAuthPath, SOURCE_AUTH) - writeFileSync(targetAuthPath, TARGET_AUTH) - if (options.sourceCredentials !== undefined) { - writeFileSync(legacyCredentialsPath, options.sourceCredentials) - } - - const counterPath = join(root, 'hash-calls') - writeFileSync(counterPath, '0') - const shimPath = join(binDir, 'sha256sum') - writeFileSync( - shimPath, - `#!/usr/bin/env node -const { createHash } = require('node:crypto') -const fs = require('node:fs') -const file = process.argv[process.argv.length - 1] -process.stdout.write( - createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n' -) -const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1 -fs.writeFileSync(process.env.HASH_COUNTER, String(calls)) -if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) { - fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES) -} -` - ) - chmodSync(shimPath, 0o755) - - let status = 0 - try { - execFileSync( - '/bin/sh', - [ - '-c', - _internals.applyLegacyAuthScript, - 'sh', - legacyHome, - join(root, 'absent-active-home'), - join(root, 'absent-marker.json'), - targetHome, - sha256(SOURCE_AUTH), - sha256(TARGET_AUTH), - '1', - '0', - options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) - ], - { - encoding: 'utf8', - env: { - ...process.env, - HASH_COUNTER: counterPath, - PATH: `${binDir}:${process.env.PATH ?? ''}`, - REWRITE_AFTER: options.rewriteSourceAfterHashCall - ? String(options.rewriteSourceAfterHashCall) - : '', - REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, - REWRITE_TARGET: - options.rewriteTarget === 'source-credentials' - ? legacyCredentialsPath - : options.rewriteTarget === 'target-auth' - ? targetAuthPath - : legacyAuthPath - }, - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 20_000 - } - ) - } catch (error) { - status = (error as { status?: number }).status ?? -1 - } - return { - legacyAuth: readFileSync(legacyAuthPath, 'utf8'), - status, - targetAuth: readFileSync(targetAuthPath, 'utf8'), - targetCredentials: existsSync(targetCredentialsPath) - ? readFileSync(targetCredentialsPath, 'utf8') - : null - } -} - -describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { - it('promotes the validated source into the account home', () => { - const outcome = runApplyScript() - expect(outcome.targetAuth).toBe(SOURCE_AUTH) - }) - - it('leaves the legacy home untouched while promoting', () => { - // One-directional: the promote step must never write back to the old home. - expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH) - }) - - it('refuses torn bytes and leaves the account home intact', () => { - // Hash call 1 is the source pre-check; rotating right after it means `cp` - // reads bytes freshness never judged. Pre-guard, those reached the target. - const outcome = runApplyScript({ rewriteSourceAfterHashCall: 1 }) - expect(outcome.status).toBe(42) - expect(outcome.targetAuth).toBe(TARGET_AUTH) - }) - - it('refuses MCP credentials that changed after host validation', () => { - const outcome = runApplyScript({ - rewriteSourceAfterHashCall: 2, - rewriteBytes: TORN_CREDENTIALS, - rewriteTarget: 'source-credentials', - sourceCredentials: SOURCE_CREDENTIALS - }) - expect(outcome.status).toBe(43) - expect(outcome.targetCredentials).toBeNull() - }) - - it('does not overwrite auth changed after the destination hash check', () => { - const outcome = runApplyScript({ - rewriteBytes: NEWER_AUTH, - rewriteSourceAfterHashCall: 4, - rewriteTarget: 'target-auth' - }) - expect(outcome.status).toBe(39) - expect(outcome.targetAuth).toBe(NEWER_AUTH) - }) -}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts deleted file mode 100644 index 0c0caa1b669..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { runWslProcessMock } = vi.hoisted(() => ({ - runWslProcessMock: vi.fn() -})) - -vi.mock('../wsl/wsl-runner', () => ({ - runWslProcess: runWslProcessMock -})) - -import { - _internals, - drainLegacyWslRuntimeAuth, - startLegacyWslRuntimeAuthDrain -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths } from './wsl-codex-auth-batch-reader' - -const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' -const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n' -const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' - -function inspection(auth: string, credentials?: string): string { - return [ - Buffer.from(auth).toString('base64'), - credentials === undefined ? 'missing' : 'present', - credentials === undefined ? '' : Buffer.from(credentials).toString('base64') - ].join('\n') -} - -function result(code: number, stdout = '') { - return { - code, - stdout, - stderr: '', - timedOut: false, - environmentResolved: true - } -} - -describe('legacy WSL runtime auth drain', () => { - beforeEach(() => { - runWslProcessMock.mockReset() - _internals.resetDrainQueue() - }) - - it('promotes fresher auth guest-side while a legacy pane remains', async () => { - runWslProcessMock - .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - .mockResolvedValueOnce(result(0)) - const resolveDestination = vi.fn(() => ({ - authContents: STALE_AUTH, - linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' - })) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination - }) - - expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH) - expect(runWslProcessMock).toHaveBeenCalledTimes(2) - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([ - '/home/alice/.local/share/orca/codex-accounts/account-1/home', - expect.any(String), - expect.any(String), - '1', - '0', - 'missing' - ]) - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') - expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600') - }) - - it('does not write when freshness cannot be proven', async () => { - runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => ({ - authContents: '{"tokens":{}}\n', - linuxHomePath: '/home/alice/.codex' - }) - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('refuses a source with no unique destination', async () => { - runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => null - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('retires stale legacy auth only after the last recorded pane exits', async () => { - runWslProcessMock - .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) - .mockResolvedValueOnce(result(0)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: () => ({ - authContents: NEWER_AUTH, - linuxHomePath: '/home/alice/.codex' - }) - }) - - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing']) - }) - - it('keeps an absent source retryable while a legacy pane remains', async () => { - runWslProcessMock.mockResolvedValueOnce(result(21)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination: vi.fn() - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('does nothing after the guest-side completion marker is present', async () => { - runWslProcessMock.mockResolvedValueOnce(result(20)) - const resolveDestination = vi.fn() - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination - }) - - expect(resolveDestination).not.toHaveBeenCalled() - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('marks an absent source complete after every legacy pane exits', async () => { - runWslProcessMock.mockResolvedValueOnce(result(21)).mockResolvedValueOnce(result(0)) - - await drainLegacyWslRuntimeAuth({ - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: false, - resolveDestination: vi.fn() - }) - - expect(runWslProcessMock).toHaveBeenCalledTimes(2) - expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([ - '/home/alice/.local/share/orca/codex-runtime-home/home', - '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home', - '/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json' - ]) - }) - - it('coalesces concurrent drain triggers instead of queueing every poll', async () => { - runWslProcessMock.mockImplementation(() => new Promise(() => {})) - const options = { - distro: 'Ubuntu', - guestHomeLinuxPath: '/home/alice', - legacyPanePresent: true, - resolveDestination: () => null - } - - startLegacyWslRuntimeAuthDrain(options) - startLegacyWslRuntimeAuthDrain(options) - await Promise.resolve() - - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - }) - - it('reads every candidate home in one bounded guest process', async () => { - runWslProcessMock.mockResolvedValueOnce( - result( - 0, - [`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join( - '\n' - ) - ) - ) - - await expect( - readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad']) - ).resolves.toEqual([ - { kind: 'present', contents: SOURCE_AUTH }, - { kind: 'missing' }, - { kind: 'unreadable' } - ]) - expect(runWslProcessMock).toHaveBeenCalledTimes(1) - expect(runWslProcessMock).toHaveBeenCalledWith( - expect.objectContaining({ - args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'], - maxOutputBytes: 2 * 1024 * 1024, - timeoutMs: 5_000 - }) - ) - }) -}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts deleted file mode 100644 index bde5d4c9ebc..00000000000 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { createHash } from 'node:crypto' -import { posix as pathPosix } from 'node:path' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' -import { runWslProcess } from '../wsl/wsl-runner' -import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' -import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' - -const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' -const MARKER_PRESENT_EXIT = 20 -const SOURCE_AUTH_ABSENT_EXIT = 21 - -export type LegacyWslRuntimeAuthDestination = { - authContents: string - linuxHomePath: string -} - -type LegacyWslRuntimeInspection = { - authContents: string - credentials: { kind: 'missing' } | { kind: 'present'; contents: string } -} - -type LegacyWslRuntimeAuthDrainOptions = { - distro: string - guestHomeLinuxPath: string - legacyPanePresent: boolean - resolveDestination: ( - runtimeAuthContents: string - ) => LegacyWslRuntimeAuthDestination | null | Promise -} - -const drainQueueByDistro = new Map>() -const completedDistroKeys = new Set() - -export function startLegacyWslRuntimeAuthDrain( - options: LegacyWslRuntimeAuthDrainOptions -): Promise { - const key = options.distro.trim().toLowerCase() - if (completedDistroKeys.has(key)) { - return Promise.resolve() - } - // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a - // new pass for every poll can otherwise build an unbounded promise chain - // while a legacy pane keeps the migration pending. - const inFlight = drainQueueByDistro.get(key) - if (inFlight) { - return inFlight - } - const next = drainLegacyWslRuntimeAuth(options) - .then((status) => { - if (status === 'complete') { - completedDistroKeys.add(key) - } - }) - .catch((error) => { - console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) - }) - drainQueueByDistro.set(key, next) - void next.finally(() => { - if (drainQueueByDistro.get(key) === next) { - drainQueueByDistro.delete(key) - } - }) - return next -} - -export async function drainLegacyWslRuntimeAuth( - options: LegacyWslRuntimeAuthDrainOptions -): Promise<'complete' | 'pending'> { - const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) - const inspection = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: INSPECT_LEGACY_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (inspection.code === MARKER_PRESENT_EXIT) { - return 'complete' - } - if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) { - if (!options.legacyPanePresent) { - await finalizeAbsentLegacyAuth(options.distro, paths) - return 'complete' - } - return 'pending' - } - assertSuccessfulDrainStep('inspect', inspection) - - const inspected = parseLegacyRuntimeInspection(inspection.stdout) - if (!inspected) { - return 'pending' - } - const destination = await options.resolveDestination(inspected.authContents) - if (!destination) { - return 'pending' - } - const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents) - if (freshness === null) { - return 'pending' - } - const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents) - const result = await runWslProcess({ - distro: options.distro, - loginPath: 'none', - script: APPLY_LEGACY_AUTH_SCRIPT, - args: [ - paths.runtimeHome, - paths.activeHome, - paths.marker, - destination.linuxHomePath, - sha256(inspected.authContents), - sha256(destination.authContents), - promoteAuth ? '1' : '0', - options.legacyPanePresent ? '0' : '1', - inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' - ], - timeoutMs: 5_000, - maxOutputBytes: 16 * 1024 - }) - assertSuccessfulDrainStep('apply', result) - return options.legacyPanePresent ? 'pending' : 'complete' -} - -function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null { - const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n') - const authContents = decodeWslBase64Payload(authBase64 ?? '') - if (authContents === null) { - return null - } - if (credentialsKind === 'missing') { - return { authContents, credentials: { kind: 'missing' } } - } - if (credentialsKind !== 'present') { - return null - } - const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '') - if (!credentialsContents || !isJsonObject(credentialsContents)) { - return null - } - return { authContents, credentials: { kind: 'present', contents: credentialsContents } } -} - -function isJsonObject(contents: string): boolean { - try { - const value = JSON.parse(contents) as unknown - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) - } catch { - return false - } -} - -function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { - activeHome: string - marker: string - runtimeHome: string -} { - const runtimeHome = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) - const runtimeRoot = pathPosix.dirname(runtimeHome) - return { - activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'), - marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME), - runtimeHome - } -} - -async function finalizeAbsentLegacyAuth( - distro: string, - paths: ReturnType -): Promise { - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: FINALIZE_ABSENT_AUTH_SCRIPT, - args: [paths.runtimeHome, paths.activeHome, paths.marker], - timeoutMs: 5_000, - maxOutputBytes: 16 * 1024 - }) - assertSuccessfulDrainStep('finalize', result) -} - -function assertSuccessfulDrainStep( - step: string, - result: { code: number | null; stderr: string; timedOut: boolean } -): void { - if (result.code === 0 && !result.timedOut) { - return - } - const detail = result.stderr.trim() - throw new Error( - `Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}` - ) -} - -function sha256(contents: string): string { - return createHash('sha256').update(contents).digest('hex') -} - -const RESOLVE_LEGACY_HOME_SCRIPT = ` -legacy_home="$1" -legacy_home_resolved=0 -if [ -e "$1" ] || [ -L "$1" ]; then - legacy_home=$(readlink -f -- "$1") || exit 30 - legacy_home_resolved=1 -fi -if [ -e "$2" ] || [ -L "$2" ]; then - active_home=$(readlink -f -- "$2") || exit 31 - if [ "$legacy_home_resolved" = 1 ]; then - [ "$active_home" = "$legacy_home" ] || exit 32 - else - legacy_home="$active_home" - fi -fi -` - -const INSPECT_LEGACY_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT} -${RESOLVE_LEGACY_HOME_SCRIPT} -source_auth="$legacy_home/auth.json" -[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT} -encode_file() { - encoded=$(base64 < "$1") || return 1 - printf '%s' "$encoded" | tr -d '\n' -} -encode_file "$source_auth" -printf '\n' -source_credentials="$legacy_home/.credentials.json" -if [ -f "$source_credentials" ]; then - printf 'present\n' - encode_file "$source_credentials" - printf '\n' -elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then - printf 'missing\n\n' -else - exit 44 -fi -` - -const APPLY_LEGACY_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit 0 -${RESOLVE_LEGACY_HOME_SCRIPT} -target_home=$(readlink -f -- "$4") || exit 33 -[ "$legacy_home" != "$target_home" ] || exit 34 -source_auth="$legacy_home/auth.json" -target_auth="$target_home/auth.json" -[ -f "$source_auth" ] || exit 35 -[ -f "$target_auth" ] || exit 36 -hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } -[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 -[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 -umask 077 -temporary_auth="$target_auth.orca-drain-$$" -temporary_credentials="$target_home/.credentials.json.orca-drain-$$" -temporary_previous_auth="$target_auth.orca-drain-previous-$$" -temporary_marker="$3.orca-drain-$$" -cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; } -trap cleanup EXIT HUP INT TERM -source_credentials="$legacy_home/.credentials.json" -target_credentials="$target_home/.credentials.json" -if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then - [ "$9" != missing ] || exit 43 - [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 - cp -- "$source_credentials" "$temporary_credentials" - chmod 600 "$temporary_credentials" - [ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43 - [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 - mv -n -- "$temporary_credentials" "$target_credentials" -elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then - [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43 -fi -if [ "$7" = 1 ]; then - cp -- "$source_auth" "$temporary_auth" - chmod 600 "$temporary_auth" - # Codex rewrites auth.json in place, so this copy is a second read: verify the - # bytes being promoted, not the ones freshness was judged on. - [ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42 - [ "$(hash_file "$target_auth")" = "$6" ] || exit 39 - # The hard link keeps the destination inode observable without creating a - # missing-path crash window. In-place writers update both names. - ln -- "$target_auth" "$temporary_previous_auth" - [ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39 - mv -f -- "$temporary_auth" "$target_auth" - if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then - mv -f -- "$temporary_previous_auth" "$target_auth" - exit 39 - fi - rm -- "$temporary_previous_auth" -fi -if [ "$8" = 1 ]; then - [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 - rm -- "$source_auth" - printf '%s\n' '{"completed":true}' > "$temporary_marker" - chmod 600 "$temporary_marker" - mv -f -- "$temporary_marker" "$3" -fi -` - -const FINALIZE_ABSENT_AUTH_SCRIPT = ` -set -eu -[ ! -f "$3" ] || exit 0 -${RESOLVE_LEGACY_HOME_SCRIPT} -[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 -umask 077 -temporary_marker="$3.orca-drain-$$" -trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM -printf '%s\n' '{"completed":true}' > "$temporary_marker" -chmod 600 "$temporary_marker" -mv -f -- "$temporary_marker" "$3" -` - -export const _internals = { - applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, - resetDrainQueue: (): void => { - drainQueueByDistro.clear() - completedDistroKeys.clear() - } -} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 7975c7172ad..cf3a0105d63 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ +import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { appendFileSync, copyFileSync, @@ -17,6 +18,7 @@ import { unlinkSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { execFileSync } from 'node:child_process' import { dirname, extname, @@ -31,6 +33,7 @@ import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { recoverInterruptedGuardedFileOperation, removeFileAtomicallyIfUnchanged, @@ -52,13 +55,16 @@ import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { + prepareSystemConfigForFreshRuntimeMirror, + syncSystemConfigIntoManagedCodexHome +} from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { - getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' @@ -85,16 +91,10 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { getCodexPaneAccount, - hasRecordedLegacyWslCodexPane, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' -import { - startLegacyWslRuntimeAuthDrain, - type LegacyWslRuntimeAuthDestination -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -130,6 +130,7 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } +type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -194,6 +195,11 @@ export class CodexRuntimeHomeService { private lastSyncedAccountId: string | null = null // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null + // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. + private readonly lastWrittenWslAuthJsonByDistro = new Map() + private readonly lastSyncedWslAccountIdByDistro = new Map() + private readonly wslRuntimeHomePathByDistro = new Map() + private skipNextReadBackForAccountId: string | null = null // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -238,11 +244,11 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - this.startLegacyWslAuthDrain(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath) - this.startWslSessionBridgeForLaunch(wslTarget, homePath) - return homePath + const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) + const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) + return runtimeHomePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -718,6 +724,26 @@ export class CodexRuntimeHomeService { syncLegacySharedCodexConfigForRetainedPanes() } + syncActiveWslSelectionsBeforeRestart(): void { + if (process.platform !== 'win32') { + return + } + + const settings = this.store.getSettings() + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + } + } + private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null @@ -764,9 +790,10 @@ export class CodexRuntimeHomeService { prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) + const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return { kind: 'ready', - codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) + codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -806,7 +833,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) + this.syncWslRuntimeForCurrentSelection(target) return } @@ -853,6 +880,7 @@ export class CodexRuntimeHomeService { // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null + this.skipNextReadBackForAccountId = null return } if (normalizeCodexRuntimeSelection(settings).host) { @@ -894,13 +922,71 @@ export class CodexRuntimeHomeService { } } - // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. + // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. clearLastWrittenAuthJson( accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host ): void { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } + this.skipNextReadBackForAccountId = accountId + } + + private readBackRefreshedTokensFromPath( + runtimeAuthPath: string, + options: { + updateLastWrittenAuthJson: boolean + lastWrittenAuthJson?: string | null + setLastWrittenAuthJson?: (contents: string) => void + expectedAccountId?: string + } + ): CodexReadBackResult { + try { + if (!existsSync(runtimeAuthPath)) { + return 'unchanged' + } + + const lastWrittenAuthJson = + options.lastWrittenAuthJson === undefined + ? this.lastWrittenAuthJson + : options.lastWrittenAuthJson + const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') + if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { + return 'unchanged' + } + + const match = this.findManagedAccountForRuntimeAuth( + runtimeContents, + options.expectedAccountId + ) + if (match.kind !== 'matched') { + if (match.kind === 'ambiguous') { + console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') + } + return 'rejected' + } + // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. + if ( + lastWrittenAuthJson === null && + !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) + ) { + return 'rejected' + } + + writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) + if (options.updateLastWrittenAuthJson) { + if (options.setLastWrittenAuthJson) { + options.setLastWrittenAuthJson(runtimeContents) + } else { + this.lastWrittenAuthJson = runtimeContents + } + } + return 'persisted' + } catch (error) { + // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. + console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) + return 'rejected' + } } // Why: which ~/.codex bytes the mirror was seeded from, and whether the system @@ -965,152 +1051,223 @@ export class CodexRuntimeHomeService { } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - return this.getWslCodexHomePathForSelection(target) - } - - private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, target) - ) - if (account) { - const managedHome = parseWslUncPath(account.managedHomePath) - const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() - // Persisted selections can outlive an account's runtime metadata. Never - // hand a host home (or another distro's UNC home) to a WSL launch. - const accountDistro = account.wslDistro?.trim() - const distroMatches = - (!accountDistro || - !targetDistro || - accountDistro.toLowerCase() === targetDistro.toLowerCase()) && - (!managedHome || - !targetDistro || - managedHome.distro.toLowerCase() === targetDistro.toLowerCase()) - // Older persisted fixtures may not carry a UNC spelling, but the - // explicit WSL runtime marker still proves the lane; reject unmarked - // host paths while retaining those legacy WSL records. - if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) { - return account.managedHomePath + const distro = target.wslDistro?.trim() + if (distro) { + const settings = this.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) + if (selectedAccountId === null) { + // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. + return this.getWslSystemCodexHomePath(target) + } + const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if ( + cachedRuntimeHomePath && + this.lastSyncedWslAccountIdByDistro.has(distro) && + this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId + ) { + // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. + return cachedRuntimeHomePath } } - return this.getWslSystemCodexHomePath(target) + return this.syncWslRuntimeForCurrentSelection(target) } - private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): Promise { + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { - return Promise.resolve() + return null } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() + + const wslTarget = this.resolveWslDefaultTarget(target) + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, wslTarget) + ) + const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() if (!distro) { - return Promise.resolve() + return null } - const guestHome = getWslHome(distro) - const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null - if (!parsedGuestHome) { - return Promise.resolve() + + const runtimeHomePath = this.getWslRuntimeHomePath(distro) + if (!runtimeHomePath) { + return null } - let legacyPanePresent: boolean - try { - legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) - } catch (error) { - console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) - return Promise.resolve() + this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) + + mkdirSync(runtimeHomePath, { recursive: true }) + this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) + + const runtimeAuthPath = join(runtimeHomePath, 'auth.json') + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null + if (previousWslAccountId) { + if (this.skipNextReadBackForAccountId === previousWslAccountId) { + this.skipNextReadBackForAccountId = null + } else { + const previousWslAccount = this.getActiveAccount( + settings.codexManagedAccounts, + previousWslAccountId + ) + if (previousWslAccount) { + this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: previousWslAccount.id + }) + } + } } - return startLegacyWslRuntimeAuthDrain({ - distro, - guestHomeLinuxPath: parsedGuestHome.linuxPath, - legacyPanePresent, - resolveDestination: (runtimeAuthContents) => - this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) - }) + + const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null + if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { + const activeAuth = readFileSync(activeAuthPath, 'utf-8') + this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) + return runtimeHomePath + } + if (activeAccount && activeAuthPath) { + console.warn( + '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' + ) + this.store.updateSettings({ + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + null, + wslTarget + ) + }) + } + + const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) + if (systemAuthPath && existsSync(systemAuthPath)) { + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null + const runtimeAuth = existsSync(runtimeAuthPath) + ? readFileSync(runtimeAuthPath, 'utf-8') + : null + if ( + runtimeAuth !== null && + runtimeAuth !== systemAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && + ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + (mirroredSystemDefaultAuth === null && + this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + ) { + // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath } - /** Preserve refreshed auth from retained legacy WSL panes before restart. */ - async syncActiveWslSelectionsBeforeRestart(): Promise { - if (process.platform !== 'win32') { + private getWslRuntimeHomePath(distro: string): string | null { + const home = getWslHome(distro) + return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null + } + + private safeReadBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + try { + this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + } catch (error) { + console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) + } + } + + private readBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() + : selectedDistroKey.trim() || account.wslDistro?.trim() + if (!distro) { return } - const settings = this.store.getSettings() - const drains: Promise[] = [] - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() || null - : selectedDistroKey.trim() || null - if (distro) { - drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) - } + + const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if (!runtimeHomePath) { + return } - await Promise.all(drains) + + this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: account.id + }) } - private async resolveLegacyWslAuthDestination( - distro: string, - runtimeAuthContents: string - ): Promise { - const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => { - const parsedHome = parseWslUncPath(account.managedHomePath) - return parsedHome?.distro.toLowerCase() === distro.toLowerCase() - }) - const accountHomes = accounts.flatMap((account) => { - const parsedHome = parseWslUncPath(account.managedHomePath) - return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : [] - }) - const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null - let reads: WslCodexAuthRead[] + private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { try { - reads = await readWslCodexAuths(distro, [ - ...accountHomes.map(({ linuxPath }) => linuxPath), - ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) - ]) - } catch { - reads = accountHomes.map(() => ({ kind: 'unreadable' })) - if (parsedSystemHome) { - reads.push({ kind: 'unreadable' }) - } - } - const authReads = new Map( - accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) - ) - const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { - accounts, - authReads - }) - if (match.kind === 'ambiguous') { - return null - } - if (match.kind === 'matched') { - const parsedHome = parseWslUncPath(match.account.managedHomePath) - if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) { - return null - } - return { - authContents: match.managedAuthContents, - linuxHomePath: parsedHome.linuxPath - } + this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) } + } - if (!systemHome || !parsedSystemHome) { - return null + private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + const runtimeWsl = parseWslUncPath(runtimeHomePath) + if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { + return } - const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } - if (systemAuth.kind !== 'present') { - return null - } - return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) - ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } - : null + const activeLinuxPath = runtimeWsl.linuxPath.replace( + /\/codex-runtime-home\/home$/, + '/codex-runtime-home/active/wsl/home' + ) + const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` + const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) + // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--exec', + 'bash', + '-lc', + [ + 'set -e', + `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + 'else', + `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, + `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, + `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, + `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, + 'fi' + ].join('\n') + ], + // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes + // a conhost and steals foreground for up to the timeout (#10488). + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } + ) + } + + private dirnameLinuxPath(value: string): string { + const index = value.lastIndexOf('/') + return index > 0 ? value.slice(0, index) : '/' } private joinWslPath(basePath: string, ...segments: string[]): string { @@ -1129,13 +1286,40 @@ export class CodexRuntimeHomeService { return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target } + private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { + const home = this.getWslSystemCodexHomePath(target) + return home ? this.joinWslPath(home, 'auth.json') : null + } + + private seedWslRuntimeHome( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): void { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + if (existsSync(runtimeConfigPath)) { + return + } + + const candidateHomes = [ + activeAccount?.managedHomePath, + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + ].filter((value): value is string => Boolean(value)) + for (const homePath of candidateHomes) { + const configPath = join(homePath, 'config.toml') + if (existsSync(configPath)) { + writeFileAtomically( + runtimeConfigPath, + prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) + ) + return + } + } + } + private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string, - options?: { - accounts: readonly CodexManagedAccount[] - authReads: ReadonlyMap - } + expectedAccountId?: string ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1143,17 +1327,18 @@ export class CodexRuntimeHomeService { managedAuthContents: string }[] = [] let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { + for (const account of this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - let managedAuthContents: string - const suppliedRead = options?.authReads.get(account.id) - if (suppliedRead?.kind === 'missing') { + if (!existsSync(managedAuthPath)) { continue } - if (suppliedRead?.kind === 'unreadable') { + let managedAuthContents: string + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { // Why: an unreadable home can never be compared, but letting the read // throw abandons the scan for every other account — dropping a refresh // the runtime home holds for one of them. Only its record can rule it @@ -1166,24 +1351,6 @@ export class CodexRuntimeHomeService { } continue } - if (suppliedRead?.kind === 'present') { - managedAuthContents = suppliedRead.contents - } else { - if (!existsSync(managedAuthPath)) { - continue - } - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } @@ -1205,6 +1372,10 @@ export class CodexRuntimeHomeService { return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) } + private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { + return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) + } + private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() try { @@ -1911,6 +2082,15 @@ export class CodexRuntimeHomeService { return true } + private writeRuntimeAuthAtPath(authPath: string, contents: string): void { + if (this.fileContentsEqual(authPath, contents)) { + this.ensureOwnerOnlyMode(authPath) + return + } + mkdirSync(dirname(authPath), { recursive: true }) + writeFileAtomically(authPath, contents, { mode: 0o600 }) + } + /** * `true`/`false` only when the bytes were actually read; `null` when the file * could not be read at all. The old `catch { return false }` reported "these @@ -2217,3 +2397,14 @@ export class CodexRuntimeHomeService { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } } + +// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). +export function prepareWslRuntimeSeedConfig( + configContents: string, + sourceHomePath: string +): string { + return prepareSystemConfigForFreshRuntimeMirror( + configContents, + parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath + ) +} diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 81593c46013..efc7ea3f2b9 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { createSettings } from './runtime-home-settings-test-fixtures' import { @@ -87,16 +87,18 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslManagedHomePath + wslRuntimeHomePath + ) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"wsl"}\n' ) - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: getRuntimeCodexHomePath() }) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ kind: 'ready', - codexHomePath: wslManagedHomePath + codexHomePath: wslRuntimeHomePath }) } finally { if (originalPlatform) { @@ -105,7 +107,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => { + it('clears a selected WSL managed account when auth.json is missing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -159,11 +161,13 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - managedHomePath + wslRuntimeHomePath ) - expect(store.updateSettings).not.toHaveBeenCalled() - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(store.updateSettings).toHaveBeenCalledWith({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -171,7 +175,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('launches WSL system default against its existing config without a runtime seed', async () => { + it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -210,18 +214,21 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - systemCodexHomePath + wslRuntimeHomePath ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') - expect(existsSync(runtimeConfigPath)).toBe(false) - const systemConfigPath = join(systemCodexHomePath, 'config.toml') - const systemConfig = readFileSync(systemConfigPath, 'utf-8') - expect(systemConfig).toContain('model_instructions_file = "instructions.md"') - expect(systemConfig).toContain('[hooks.state.') + const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') + expect(runtimeConfig).toContain( + `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'` + ) + expect(runtimeConfig).toContain('[projects."/home/alice/repo"]') + expect(runtimeConfig).not.toContain('[hooks.state.') - writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8') + // Why: WSL runtime configs are seeded once; Codex writes trust into them + // afterwards, so a relaunch must not clobber the seeded file. + writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -229,7 +236,26 @@ describe('CodexRuntimeHomeService', () => { } }) - it('switches WSL accounts by selecting each account home directly', async () => { + it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => { + const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service') + + // Why: real UNC sources cannot back live fs operations in tests, so pin + // the UNC -> Linux-side anchor translation on the extracted seed function. + expect( + prepareWslRuntimeSeedConfig( + 'model_instructions_file = "instructions.md"\n', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + ) + ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + expect( + prepareWslRuntimeSeedConfig( + 'model_instructions_file = "instructions.md"\n', + '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' + ) + ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + }) + + it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -291,17 +317,16 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath) - expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) store.updateSettings({ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } }) service.syncForCurrentSelection(target) - expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath) - expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) - expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -309,7 +334,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('ignores stale retired runtime auth when launching a managed WSL account', async () => { + it('does not use host auth baseline to accept stale WSL runtime auth', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -317,6 +342,7 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) + const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', 'acct-wsl', @@ -329,6 +355,7 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) + const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -347,6 +374,17 @@ describe('CodexRuntimeHomeService', () => { const store = createStore( createSettings({ codexManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedHomePath: hostManagedHomePath, + providerAccountId: 'acct-host', + workspaceLabel: null, + workspaceAccountId: 'acct-host', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, { id: 'wsl-account', email: 'wsl@example.com', @@ -357,13 +395,14 @@ describe('CodexRuntimeHomeService', () => { providerAccountId: 'acct-wsl', workspaceLabel: null, workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 } ], + activeCodexManagedAccountId: 'host-account', activeCodexManagedAccountIdsByRuntime: { - host: null, + host: 'host-account', wsl: { Ubuntu: 'wsl-account' } } }) @@ -374,10 +413,148 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslManagedHomePath + wslRuntimeHomePath ) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) + const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) + const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') + + service.clearLastWrittenAuthJson('account-1') + service.syncForCurrentSelection(target) + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('reads active WSL token refreshes back before restart using the selected distro', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000) + const refreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'runtime-refreshed', + 2_000 + ) + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + const managedAuthPath = join(managedHomePath, 'auth.json') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: null, + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'account-1' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + + service.syncActiveWslSelectionsBeforeRestart() + + expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index fe91d223a40..05cfa12e240 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type * as WslPaths from '../../shared/wsl-paths' import { createSettings } from './runtime-home-settings-test-fixtures' @@ -34,7 +34,62 @@ describe('CodexRuntimeHomeService', () => { teardownRuntimeHomeTest() }) - it('skips WSL session bridging when system default already uses its direct home', async () => { + it('builds a valid WSL legacy active-home migration shell command', async () => { + const execFileSyncMock = vi.fn() + vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock })) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService( + createStore(createSettings()) as never + ) as unknown as { + migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void + } + + service.migrateLegacyWslActiveHomePointer( + 'Ubuntu', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + ) + + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + const firstCall = execFileSyncMock.mock.calls[0] + expect(firstCall).toBeDefined() + const [command, args] = firstCall as [string, string[]] + expect(command).toBe('wsl.exe') + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) + expect(args).toHaveLength(6) + + const shellCommand = args[5] + expect(shellCommand).toContain( + "if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" + ) + expect(shellCommand).toContain( + "elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" + ) + expect(shellCommand).toContain( + "mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'" + ) + expect(shellCommand).toContain( + "ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" + ) + expect(shellCommand).toContain( + "mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" + ) + expect(shellCommand).toContain( + "' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'" + ) + expect(shellCommand).not.toContain('[! -L') + expect(shellCommand).not.toContain('mv -Tf--') + expect(shellCommand).not.toContain('$1') + expect(shellCommand).not.toContain('$2') + expect(shellCommand).not.toContain('$3') + expect(shellCommand).not.toContain('exit 0') + } finally { + vi.doUnmock('node:child_process') + } + }) + + it('starts WSL session bridging after materializing the WSL launch home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -69,13 +124,17 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslSystemHomePath + wslRuntimeHomePath ) - expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() - expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe( - '# WSL instructions\n' - ) - expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Ubuntu', + systemCodexHomePath: wslSystemHomePath, + managedCodexHomePath: wslRuntimeHomePath + }) + const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -85,7 +144,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps WSL in-Codex setting changes in the direct system home', async () => { + it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../codex/wsl-codex-session-bridge', () => ({ @@ -118,20 +177,32 @@ describe('CodexRuntimeHomeService', () => { 'home' ) + // First launch seeds the runtime config and records the per-distro baseline. expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - join(wslHome, '.codex') + wslRuntimeHomePath ) const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') - expect(existsSync(baselinePath)).toBe(false) + expect(existsSync(baselinePath)).toBe(true) + // A direct WSL Codex edit wins and is mirrored into Orca's runtime before + // the baseline advances, so later in-Orca changes remain promotable. + const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') + + // Codex now persists a /model change inside Orca's reconciled runtime. + writeFileSync( + runtimeConfigPath, + readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), + 'utf-8' + ) - writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') - expect(existsSync(baselinePath)).toBe(false) + // Baseline advances so the promoted value is not re-promoted forever. + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -165,13 +236,22 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', systemCodexHomePath: '/home/me/.config/codex', - managedCodexHomePath: join(wslHome, '.codex') + managedCodexHomePath: wslRuntimeHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -182,7 +262,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the selected direct account home', async () => { + it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -190,6 +270,14 @@ describe('CodexRuntimeHomeService', () => { startWslCodexSessionBridgeInBackground })) const wslHome = join(testState.userDataDir, 'debian-wsl-home') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => null, getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) @@ -199,10 +287,10 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate.includes('codex-accounts/debian-account/home') + candidate === wslRuntimeHomePath ? { distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home' + linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' } : null } @@ -240,12 +328,12 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( - managedHomePath + wslRuntimeHomePath ) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Debian', systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: managedHomePath + managedCodexHomePath: wslRuntimeHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index 8e8965f6a40..a1ad86a0a5f 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -53,6 +53,15 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + const syncWslRuntime = vi.spyOn( + service as unknown as { + syncWslRuntimeForCurrentSelection: (target: { + runtime: 'wsl' + wslDistro?: string | null + }) => string | null + }, + 'syncWslRuntimeForCurrentSelection' + ) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') @@ -64,6 +73,7 @@ describe('CodexRuntimeHomeService', () => { kind: 'ready', codexHomePath: expectedHome }) + expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -83,6 +93,15 @@ describe('CodexRuntimeHomeService', () => { const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) + const runtimeAuthPath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home', + 'auth.json' + ) const store = createStore( createSettings({ codexManagedAccounts: [ @@ -128,9 +147,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ kind: 'ready', - codexHomePath: ubuntuHomePath + codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') }) - expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -209,7 +228,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('keeps WSL system-default token refreshes in its direct home', async () => { + it('reads WSL system-default token refreshes back to WSL system auth', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -238,11 +257,21 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) - writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') + + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -250,7 +279,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => { + it('preserves WSL system-default token refreshes after app restart', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -290,8 +319,8 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts deleted file mode 100644 index 40870fcd5ef..00000000000 --- a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { runWslProcess } from '../wsl/wsl-runner' - -export type WslCodexAuthRead = - | { kind: 'missing' | 'unreadable' } - | { kind: 'present'; contents: string } - -export async function readWslCodexAuths( - distro: string, - linuxHomePaths: readonly string[] -): Promise { - if (linuxHomePaths.length === 0) { - return [] - } - const result = await runWslProcess({ - distro, - loginPath: 'none', - script: READ_AUTHS_SCRIPT, - args: linuxHomePaths, - timeoutMs: 5_000, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (result.code !== 0 || result.timedOut) { - return linuxHomePaths.map(() => ({ kind: 'unreadable' })) - } - const rows = result.stdout.split('\n') - return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? '')) -} - -export function decodeWslBase64Payload(encoded: string): string | null { - try { - const decoded = Buffer.from(encoded, 'base64') - const canonical = decoded.toString('base64').replace(/=+$/, '') - return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null - } catch { - return null - } -} - -function parseAuthReadRow(row: string): WslCodexAuthRead { - if (row === 'missing' || row === 'unreadable') { - return { kind: row } - } - if (!row.startsWith('present:')) { - return { kind: 'unreadable' } - } - const contents = decodeWslBase64Payload(row.slice('present:'.length)) - return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents } -} - -const READ_AUTHS_SCRIPT = ` -set -eu -for home_path in "$@"; do - auth_path="$home_path/auth.json" - if [ ! -f "$auth_path" ]; then - if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then - printf 'missing\n' - else - printf 'unreadable\n' - fi - continue - fi - if encoded=$(base64 < "$auth_path"); then - printf 'present:' - printf '%s' "$encoded" | tr -d '\n' - printf '\n' - else - printf 'unreadable\n' - fi -done -` diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 4a4467bd219..449c11ba711 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -266,15 +266,6 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } -/** True when a retained WSL pane may still read the retired per-distro runtime home. */ -export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { - return Object.values(readRegistryOrThrow().panes).some( - (record) => - (record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') && - (record.homeRoute === undefined || record.homeRoute === 'wsl-home') - ) -} - /** True when startup may need to repair hooks for a retained managed host pane. */ export function hasRecordedManagedHostCodexPane(): boolean { return Object.values(readRegistry().panes).some( diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index c862099e3ac..456b9bc08be 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'account-home' + homeRoute: 'wsl-home' }) }) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 089c1975db3..b99700da081 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,6 +1,5 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { parseWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getCodexSelectionTargetForAccount, @@ -81,6 +80,9 @@ function resolveCodexPaneHomeRoute(args: { settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneHomeRoute { + if (args.target.runtime === 'wsl') { + return 'wsl-home' + } if ( !args.launchCodexHomePath || normalizeRuntimePathForComparison(args.launchCodexHomePath) === @@ -91,19 +93,10 @@ function resolveCodexPaneHomeRoute(args: { const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) const accountOwnsHome = args.settings.codexManagedAccounts?.some( (account) => - getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === - getCodexSelectionLaneKey(args.target) && + getCodexSelectionTargetForAccount(account).runtime === 'host' && normalizeRuntimePathForComparison(account.managedHomePath) === launchHome ) - if (accountOwnsHome) { - return 'account-home' - } - if (args.target.runtime === 'wsl') { - return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex') - ? 'real-home' - : 'wsl-home' - } - return 'shared-home' + return accountOwnsHome ? 'account-home' : 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index c5deb104543..bf2f7e5b5ea 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,7 +8,6 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, - hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -139,36 +138,6 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) - it('identifies only legacy runtime-home panes on the requested WSL lane', () => { - recordCodexPaneAccount('pty-legacy', { - selectionKey: 'wsl:Ubuntu', - accountId: 'account-old', - homeRoute: 'wsl-home' - }) - recordCodexPaneAccount('pty-direct', { - selectionKey: 'wsl:Ubuntu', - accountId: 'account-new', - homeRoute: 'account-home' - }) - recordCodexPaneAccount('pty-other-distro', { - selectionKey: 'wsl:Debian', - accountId: 'account-debian', - homeRoute: 'wsl-home' - }) - recordCodexPaneAccount('pty-default', { - selectionKey: 'wsl:__default__', - accountId: null, - homeRoute: 'wsl-home' - }) - - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) - forgetCodexPaneAccount('pty-legacy') - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) - forgetCodexPaneAccount('pty-default') - expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) - expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true) - }) - it('requests startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index 6667e3fd608..f61272de8c3 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,4 +1,6 @@ -/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */ +/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync + * with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which + * builds the UNC twin of this path. */ export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ '.local', 'share', diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index e226814ba84..befefb5a2c8 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,6 +17,7 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts +main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index dbe9c0c2aa4..03e06eb374f 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,6 +41,7 @@ src/main/browser/browser-cookie-import.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts +src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-session.ts From aaef5e8c9fbd201cfb8003f268badfc6b7a1610b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:17:34 -0700 Subject: [PATCH 6/9] fix(agent-hooks): deliver hook events that fire while Orca is restarting (STA-5329) (#16685) * fix(agent-hooks): correct durable spool delivery * fix(agent-hooks): spool curl failures after retries * fix(agent-hooks): keep replay out of runtime observations * test(agent-hooks): pin managed hooks inert outside an Orca terminal * fix(agent-hooks): address review findings on the durable spool - claude: pass the literal source; options.agent does not exist (typecheck) - kimi: the windows-local ordering runs its guard pre-stdin and before the function exists, so it no longer spools there (printed command-not-found) - writer: require a readable endpoint file before creating a spool tree - antigravity: carry its out-of-band event name into the record and filter on it - drain: truncate only the bytes consumed, preserving concurrent appends and a torn trailing line * fix(agent-hooks): ignore spool events without pane attribution * fix(agent-hooks): make spool replay and appends robust * test(agent-hooks): type spool replay records * fix(agent-hooks): defer unterminated spool records * fix(agent-hooks): replay spool events through relays * fix(agent-hooks): preserve Codex prompt across child replay * fix(relay): keep startup alive when spool replay fails * fix(relay): simplify spool replay startup guard --- .../hook-script-outside-orca.test.ts | 82 +++++ src/main/agent-hooks/hook-stdin-contract.ts | 46 +++ .../server-relay-listener-replay.test.ts | 1 + src/main/agent-hooks/server.ts | 71 ++++- src/main/agent-hooks/spool.test.ts | 289 ++++++++++++++++++ src/main/antigravity/hook-script.ts | 5 +- src/main/claude/hook-service.ts | 5 +- src/main/codex/hook-service.ts | 9 +- .../command-code-managed-script.ts | 5 +- src/main/copilot/copilot-managed-script.ts | 9 +- src/main/cursor/hook-service.ts | 5 +- src/main/devin/hook-service.ts | 5 +- src/main/droid/hook-service.ts | 5 +- src/main/gemini/hook-service.ts | 5 +- src/main/grok/grok-hook-script.ts | 9 +- src/main/kimi/hook-service.ts | 22 +- src/relay/agent-hook-server.test.ts | 75 ++++- src/relay/agent-hook-server.ts | 40 ++- .../providers/codex-state.ts | 7 + src/shared/agent-hook-spool.ts | 172 +++++++++++ 20 files changed, 844 insertions(+), 23 deletions(-) create mode 100644 src/main/agent-hooks/hook-script-outside-orca.test.ts create mode 100644 src/main/agent-hooks/spool.test.ts create mode 100644 src/shared/agent-hook-spool.ts diff --git a/src/main/agent-hooks/hook-script-outside-orca.test.ts b/src/main/agent-hooks/hook-script-outside-orca.test.ts new file mode 100644 index 00000000000..6e9a27f6acc --- /dev/null +++ b/src/main/agent-hooks/hook-script-outside-orca.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { _internals as codexInternals } from '../codex/hook-service' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +/** Managed hooks are installed into the user's agent config, so they also run when the + * agent is launched from a plain terminal. There they must be inert and silent. */ +function runHook(dir: string, extraEnv: NodeJS.ProcessEnv = {}) { + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + const clean: NodeJS.ProcessEnv = {} + for (const [k, v] of Object.entries(process.env)) { + if (!k.startsWith('ORCA_')) { + clean[k] = v + } + } + return spawnSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { ...clean, ...extraEnv }, + timeout: 5000, + encoding: 'utf8' + }) +} + +describe('managed hook outside an Orca terminal', () => { + it('no Orca env at all: silent, exit 0, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-')) + const res = runHook(dir) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('pane key present but no endpoint: still silent and writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-partial-')) + const res = runHook(dir, { ORCA_PANE_KEY: 'tab:0', ORCA_TAB_ID: 'tab' }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('endpoint points at a path that does not exist: silent, exit 0', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-stale-')) + const res = runHook(dir, { + ORCA_AGENT_HOOK_ENDPOINT: join(dir, 'gone', 'deeper', 'endpoint.env'), + ORCA_PANE_KEY: 'tab:0' + }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + // a stale env var must not create a spool tree for an Orca that is not installed here + expect(readdirSync(dir)).toEqual(['codex-hook.sh']) + }) + + it('readable endpoint without a pane key: silent, writes nothing', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-outside-readable-')) + const endpoint = join(dir, 'endpoint.env') + writeFileSync(endpoint, 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\n') + const res = runHook(dir, { ORCA_AGENT_HOOK_ENDPOINT: endpoint }) + expect(res.status).toBe(0) + expect(res.stdout).toBe('') + expect(res.stderr).toBe('') + expect(readdirSync(dir).sort()).toEqual(['codex-hook.sh', 'endpoint.env']) + }) +}) + +describe('antigravity out-of-band event name', () => { + it('records hookEventName and filters tool progress on it', () => { + const lines = buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT').join('\n') + expect(lines).toContain('"hookEventName":"%s"') + expect(lines).toContain('${ORCA_ANTIGRAVITY_EVENT:-}') + expect(lines).toContain('in PreToolUse|PostToolUse|PostToolUseFailure) return 0') + // payload-based filtering stays the default for every other provider + expect(buildPosixHookSpoolLines('codex').join('\n')).toContain('case "$payload" in') + }) +}) diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index fb7b3d85487..acba7927650 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -22,6 +22,52 @@ export function buildPosixHookPayloadCapture( ] } +/** Shell-side durable fallback shared by every POSIX managed hook. + * `eventNameVar` is for providers that send the event name out-of-band rather than in the + * payload JSON; without it both the progress filter and replay would miss the event name. */ +export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): string[] { + // Why: the event name must be a printf ARG, not inlined in the single-quoted format, + // where a command substitution would be emitted literally. + const eventFormat = eventNameVar ? '"hookEventName":"%s",' : '' + const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : '' + const spoolRecordLine = " { printf '\\n{".concat( + eventFormat, + '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'', + eventArg, + ' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "', + source, + '")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :' + ) + return [ + 'spool_hook_event() {', + eventNameVar + ? ` case "\${${eventNameVar}:-}" in PreToolUse|PostToolUse|PostToolUseFailure) return 0 ;; esac` + : ' case "$payload" in *\'"PreToolUse"\'*|*\'"PostToolUse"\'*|*\'"PostToolUseFailure"\'*) return 0 ;; esac', + ' [ -n "${ORCA_AGENT_HOOK_ENDPOINT:-}" ] || return 0', + // Why: an endpoint can linger in a parent shell after leaving Orca; without a pane key + // the record is un-attributable and would accumulate as pane-unknown.jsonl. + ' [ -n "${ORCA_PANE_KEY:-}" ] || return 0', + // Why: a stale env var must not create a spool tree for an Orca that is not installed here. + ' [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ] || return 0', + ' spool_base=${ORCA_AGENT_HOOK_ENDPOINT%/*}', + ' spool_dir="$spool_base/spool"', + ' mkdir -p "$spool_dir" 2>/dev/null || return 0', + ' chmod 700 "$spool_dir" 2>/dev/null || :', + " spool_id=$(printf %s \"${ORCA_PANE_KEY:-unknown}\" | tail -c 36 | tr '/:' '__')", + ' spool_file="$spool_dir/pane-$spool_id.jsonl"', + ' if [ -f "$spool_file" ] && find "$spool_file" -mtime +7 -print -quit 2>/dev/null | grep -q .; then : > "$spool_file"; fi', + ' [ -f "$spool_file" ] || : > "$spool_file"', + ' spool_size=$(wc -c < "$spool_file" 2>/dev/null || printf 0)', + ' [ "$spool_size" -lt 5242880 ] || return 0', + ' spool_now=$(date +%s 2>/dev/null || printf 0)', + ' spool_now=$((spool_now * 1000))', + ' spool_json_escape() { printf %s "$1" | sed \'s/\\\\/\\\\\\\\/g; s/"/\\\\"/g; s/[[:cntrl:]]/ /g\'; }', + spoolRecordLine, + ' chmod 600 "$spool_file" 2>/dev/null || :', + '}' + ] +} + export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' // Why: qualify the stdin reader because Windows searches the worktree for // executables before PATH and hook payloads must not reach repo-local code. diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index 6c96d9c3bb8..0bd9b4b29ef 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -63,6 +63,7 @@ describe('AgentHookServer listener replay', () => { send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' }) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', + prompt: 'coordinate reviewers', model: 'gpt-5.4', providerSession: { key: 'session_id', id: 'root-session' }, subagents: [expect.objectContaining({ id: 'child-b' })] diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e447be40262..ba29d3bc211 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -116,6 +116,12 @@ import { type AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' +import { + buildSpoolHookBody, + drainAgentHookSpool, + launchTokenHash, + type SpoolRecord +} from '../../shared/agent-hook-spool' export type { AgentHookSource } @@ -801,6 +807,44 @@ export class AgentHookServer { } } + /** Replay is durable evidence from a prior runtime, not a live observation. */ + private withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { this.onPaneStatusCleared = listener } @@ -2259,14 +2303,14 @@ export class AgentHookServer { claudeRunningNonAgentTask?: unknown payload: unknown }, - connectionId: string + connectionId: string | null ): void { // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (typeof connectionId !== 'string') { + if (connectionId !== null && typeof connectionId !== 'string') { return } - const trimmedConnectionId = connectionId.trim() - if (trimmedConnectionId.length === 0) { + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { return } if (!envelope || typeof envelope.paneKey !== 'string') { @@ -2286,6 +2330,14 @@ export class AgentHookServer { if (!parsedPaneKey) { return } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { return } @@ -2515,6 +2567,15 @@ export class AgentHookServer { this.hydrateLastStatusFromDisk() } this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { res.writeHead(404) @@ -3309,6 +3370,8 @@ export class AgentHookServer { restoredUnconfirmed: _restoredUnconfirmed, // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts new file mode 100644 index 00000000000..3e460fdc3ba --- /dev/null +++ b/src/main/agent-hooks/spool.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest' +import { + appendFileSync, + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + statSync, + writeFileSync +} from 'node:fs' +import { execFileSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + AGENT_HOOK_SPOOL_MAX_FILES, + drainAgentHookSpool, + launchTokenHash, + readSpoolRecords, + type SpoolRecord +} from '../../shared/agent-hook-spool' +import { AgentHookServer, _internals } from './server' +import { buildBody } from './server.test-fixtures' +import { _internals as codexInternals } from '../codex/hook-service' +import { makePaneKey } from '../../shared/stable-pane-id' +import { buildPosixHookSpoolLines } from './hook-stdin-contract' + +describe('agent hook spool', () => { + it('appends each record with one printf write to prevent concurrent field interleaving', () => { + const spoolLine = buildPosixHookSpoolLines('codex').find((line) => + line.includes('>> "$spool_file"') + ) + expect(spoolLine).toBeDefined() + expect(spoolLine!.match(/printf/g)).toHaveLength(1) + expect(spoolLine).toContain('"$spool_now" "$payload"') + }) + + it('drops torn lines while retaining complete records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const file = join(dir, 'pane.jsonl') + writeFileSync( + file, + '\n{"paneKey":"tab:1","source":"codex","receivedAt":1,"payload":{}}\n{"paneKey":' + ) + expect(readSpoolRecords(file, 1)).toHaveLength(1) + }) + + it('waits for a newline before replaying a complete-looking final record', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-unterminated-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-live.jsonl') + const record = JSON.stringify({ + paneKey: 'tab:live', + source: 'codex', + receivedAt: Date.now(), + payload: { state: 'done' } + }) + writeFileSync(file, record) + const ingested: SpoolRecord[] = [] + const options = { + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (value: SpoolRecord) => ingested.push(value) + } + expect(readSpoolRecords(file)).toHaveLength(0) + expect(drainAgentHookSpool(options)).toBe(0) + expect(readFileSync(file, 'utf8')).toBe(record) + + appendFileSync(file, '\n') + expect(drainAgentHookSpool(options)).toBe(1) + expect(ingested).toHaveLength(1) + expect(readFileSync(file)).toHaveLength(0) + }) + + it('does not let historical empty pane files starve newer records', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-empty-files-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + for (let index = 0; index < AGENT_HOOK_SPOOL_MAX_FILES; index += 1) { + writeFileSync(join(spool, `pane-empty-${index}.jsonl`), '') + } + const live = join(spool, 'pane-live.jsonl') + writeFileSync( + live, + `\n${JSON.stringify({ paneKey: 'tab:live', source: 'codex', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: SpoolRecord[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]?.paneKey).toBe('tab:live') + }) + + it('rejects stale launch tokens before ingest and truncates in place', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'old', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const inode = statSync(file).ino + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('new')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(0) + expect(readFileSync(file)).toHaveLength(0) + expect(statSync(file).ino).toBe(inode) + }) + + it('ingests a record with the matching launch token', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-')) + const spool = join(dir, 'spool') + mkdirSync(spool) + const file = join(spool, 'pane-1.jsonl') + writeFileSync( + file, + `\n${JSON.stringify({ paneKey: 'tab:1', source: 'codex', launchToken: 'same', receivedAt: Date.now(), payload: { state: 'done' } })}\n` + ) + const ingested: unknown[] = [] + drainAgentHookSpool({ + endpointDir: dir, + getPersistedLaunchTokenHash: () => launchTokenHash('same')!, + ingest: (record) => ingested.push(record) + }) + expect(ingested).toHaveLength(1) + expect(ingested[0]).toMatchObject({ paneKey: 'tab:1', isReplay: true }) + }) + + it('replays a spooled Codex SubagentStop through the server after restart', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-e2e-')) + const paneKey = makePaneKey('tab-spool', '00000000-0000-4000-8000-000000000001') + const launchToken = 'generation-token' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-spooled' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + 'spool-test' + ) + expect(first.getStatusSnapshot()).toHaveLength(1) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-tab-spooled_0.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStop', launchToken, receivedAt: Date.now(), payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' } })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + const snapshot = restarted.getStatusSnapshot() + expect(snapshot).toHaveLength(1) + expect(snapshot[0]!.subagents).toBeUndefined() + restarted.flushStatusPersistSync() + expect(readFileSync(restarted.lastStatusPath!, 'utf8')).not.toContain('isReplay') + } finally { + restarted.stop() + } + }) + + it('rejects a stale remote replay against the hydrated launch-token fence', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-remote-fence-')) + const paneKey = makePaneKey('tab-remote-fence', '00000000-0000-4000-8000-000000000003') + const first = new AgentHookServer() + const second = new AgentHookServer() + try { + await first.start({ env: 'production', userDataPath }) + first.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'old-generation', + payload: { state: 'working', agentType: 'codex', prompt: 'old' } + }, + 'ssh-1' + ) + first.flushStatusPersistSync() + first.stop() + + await second.start({ env: 'production', userDataPath }) + second.ingestRemote( + { + paneKey, + source: 'codex', + launchToken: 'new-generation', + isReplay: true, + payload: { state: 'done', agentType: 'codex', prompt: 'stale completion' } + }, + 'ssh-2' + ) + expect(second.getStatusSnapshot()[0]).toMatchObject({ + paneKey, + state: 'working', + prompt: 'old' + }) + } finally { + first.stop() + second.stop() + } + }) + + it('spools when the endpoint is present but the receiver is unavailable', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-spool-failure-')) + const endpointDir = join(dir, 'agent-hooks') + mkdirSync(endpointDir, { recursive: true }) + const endpoint = join(endpointDir, 'endpoint.env') + writeFileSync( + endpoint, + 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\nORCA_AGENT_HOOK_ENV=production\nORCA_AGENT_HOOK_VERSION=1\n' + ) + const script = join(dir, 'codex-hook.sh') + writeFileSync(script, codexInternals.getManagedScript('posix')) + chmodSync(script, 0o755) + execFileSync('/bin/sh', [script], { + input: '{"hook_event_name":"SubagentStop","agent_id":"child"}\n', + env: { + ...process.env, + ORCA_AGENT_HOOK_ENDPOINT: endpoint, + ORCA_PANE_KEY: 'tab-failure:0', + ORCA_TAB_ID: 'tab-failure', + ORCA_AGENT_LAUNCH_TOKEN: 'generation-token' + }, + timeout: 5000 + }) + const spoolFiles = readdirSync(join(endpointDir, 'spool')) + expect(spoolFiles).toHaveLength(1) + expect(readFileSync(join(endpointDir, 'spool', spoolFiles[0]!), 'utf8')).toContain( + 'SubagentStop' + ) + }) + + it('does not mark a non-terminal downtime replay as runtime-observed', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-spool-observed-')) + const paneKey = makePaneKey('tab-observed', '00000000-0000-4000-8000-000000000002') + const launchToken = 'observed-generation' + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'child-observed' }), + 'production' + )! + first.ingestRemote( + { + paneKey, + source: 'codex', + hookEventName: 'SubagentStart', + launchToken, + payload: started.payload + }, + null + ) + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + writeFileSync( + join(spoolDir, 'pane-observed.jsonl'), + `\n${JSON.stringify({ paneKey, source: 'codex', hookEventName: 'SubagentStart', launchToken, receivedAt: Date.now(), payload: started.payload })}\n` + ) + const restarted = new AgentHookServer() + await restarted.start({ env: 'production', userDataPath }) + try { + expect(restarted.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) + } finally { + restarted.stop() + } + }) +}) diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 0645416c005..67f1f639ef9 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -1,5 +1,6 @@ import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_COMMAND @@ -55,10 +56,12 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: some Antigravity events arrive without stdin but still need a // status post, so the shared capture maps empty input to an object. ...buildPosixHookPayloadCapture('empty-object'), + ...buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -76,7 +79,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 4de81077ab2..3dba1121a20 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -17,6 +17,7 @@ import { import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_LABEL @@ -96,6 +97,7 @@ function getManagedScript( // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('claude'), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -115,6 +117,7 @@ function getManagedScript( ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. @@ -129,7 +132,7 @@ function getManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 6dc78fdabf1..a25f14bcd89 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -31,6 +31,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue, POSIX_HOOK_STDIN_DRAIN_COMMAND @@ -818,6 +819,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('codex'), // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', @@ -844,6 +846,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'post_codex_hook() {', @@ -875,9 +878,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if is_wsl_runtime; then', ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', - ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' if post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1; then', + ' exit 0', + ' fi', + ' # post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', ' fi', 'fi', + 'spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts index ca45f2fa579..59ea79b9d3a 100644 --- a/src/main/command-code/command-code-managed-script.ts +++ b/src/main/command-code/command-code-managed-script.ts @@ -1,6 +1,7 @@ import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -36,6 +37,7 @@ export function buildCommandCodeManagedScript( return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('command-code'), '__orca_read_ancestor_var() {', ' __orca_name="$1"', ' __orca_pid="${PPID:-}"', @@ -119,6 +121,7 @@ export function buildCommandCodeManagedScript( ' done', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -135,7 +138,7 @@ export function buildCommandCodeManagedScript( ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/copilot/copilot-managed-script.ts b/src/main/copilot/copilot-managed-script.ts index 83b40e51d1b..492caafc045 100644 --- a/src/main/copilot/copilot-managed-script.ts +++ b/src/main/copilot/copilot-managed-script.ts @@ -1,5 +1,8 @@ import { getSharedManagedScriptPath } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' export function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh' @@ -53,12 +56,14 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { '#!/bin/sh', "printf '{}\\n'", ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('copilot'), // Why: Copilot consumes stdout for some hooks, so stdout is emitted before // endpoint refresh, stdin parsing, or the network POST can fail. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline @@ -75,7 +80,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index ab9d07b25eb..310afad939c 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -23,6 +23,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -76,11 +77,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('cursor'), // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. @@ -95,7 +98,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 6c485b0daf5..a5e3667551e 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -13,6 +13,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -55,12 +56,14 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('devin'), // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open). 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a filesystem path, so hand-building JSON in shell is unsafe (quotes/newlines); post as form fields instead. @@ -75,7 +78,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index fdef56a2401..3bdb715427f 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -94,10 +95,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('droid'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Timeout caps best-effort hook posts if the local listener stalls. @@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 72ee056bd5f..6fd529e96e4 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -24,6 +24,7 @@ import { } from '../agent-hooks/installer-utils-remote' import { buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' @@ -72,11 +73,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('gemini'), // Why: source refreshes endpoint coords so a PTY surviving an Orca restart keeps reporting. See claude/hook-service.ts. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', // Why: worktreeId embeds a path, so post form fields, not hand-built JSON that breaks on quotes/newlines. @@ -91,7 +94,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/grok/grok-hook-script.ts b/src/main/grok/grok-hook-script.ts index cf4c57580b9..997bf7e64d4 100644 --- a/src/main/grok/grok-hook-script.ts +++ b/src/main/grok/grok-hook-script.ts @@ -3,7 +3,10 @@ import { wrapPosixHookCommand, wrapWindowsCmdHookCommand } from '../agent-hooks/installer-utils' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { buildWindowsGrokHookScript, GROK_HOME_ENVELOPE_MAX_LENGTH @@ -33,10 +36,12 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('grok'), 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', ' exit 0', 'fi', 'grok_home=', @@ -54,7 +59,7 @@ export function getGrokManagedScript(target: 'local' | 'posix' = 'local'): strin ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', ' --data-urlencode "grokHome=${grok_home}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index a7b954f01cb..e397fa0b026 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -24,7 +24,10 @@ import { writeManagedScriptRemote, writeTextFileRemoteAtomic } from '../agent-hooks/installer-utils-remote' -import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines +} from '../agent-hooks/hook-stdin-contract' import { applyManagedKimiHooks, KIMI_HOOK_EVENTS, @@ -71,14 +74,25 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + // Why: the windows-local ordering runs this guard before stdin is read and before + // spool_hook_event is defined, so only the payload-first ordering may spool here. + ...(windowsLocal ? [] : [' spool_hook_event']), ' exit 0', 'fi' ] return [ '#!/bin/sh', ...(windowsLocal - ? [...endpointRefreshAndGuard, ...buildPosixHookPayloadCapture()] - : [...buildPosixHookPayloadCapture(), ...endpointRefreshAndGuard]), + ? [ + ...endpointRefreshAndGuard, + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi') + ] + : [ + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('kimi'), + ...endpointRefreshAndGuard + ]), // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -95,7 +109,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', 'exit 0', '' ].join('\n') diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 0e3771aff55..e247706b5dc 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' @@ -82,6 +82,79 @@ describe('RelayAgentHookServer', () => { } }) + it('normalizes and forwards raw spooled hooks on startup', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'remote', + version: '1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + source: 'codex', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + + await server.start() + try { + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + launchToken: 'generation-token', + hookEventName: 'SubagentStop', + isReplay: true, + env: 'remote', + version: '1', + payload: { state: 'working', agentType: 'codex' } + }) + expect(readFileSync(spoolFile)).toHaveLength(0) + } finally { + server.stop() + } + }) + + it('keeps the relay listening when spool replay forwarding fails', async () => { + const spoolDir = join(dir, 'spool') + const spoolFile = join(spoolDir, 'pane-codex.jsonl') + mkdirSync(spoolDir) + writeFileSync( + spoolFile, + `${JSON.stringify({ + paneKey: PANE_KEY, + source: 'codex', + hookEventName: 'SubagentStop', + payload: { hook_event_name: 'SubagentStop', agent_id: 'child-spooled' }, + receivedAt: Date.now() + })}\n` + ) + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('receiver unavailable') + } + }) + + try { + await expect(server.start()).resolves.toBeUndefined() + expect(server.getCoordinates().port).toBeGreaterThan(0) + expect(readFileSync(spoolFile, 'utf8')).not.toBe('') + } finally { + server.stop() + } + }) + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { const order: string[] = [] let server!: RelayAgentHookServer diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 84474cbab00..ec980470dd4 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -24,10 +24,16 @@ import { isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference' import { + isAgentHookSource, REMOTE_AGENT_HOOK_ENV, type AgentHookRelayEnvelope, type AgentHookSource } from '../shared/agent-hook-relay' +import { + buildSpoolHookBody, + drainAgentHookSpool, + type SpoolRecord +} from '../shared/agent-hook-spool' import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates' import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' @@ -100,6 +106,19 @@ export class RelayAgentHookServer { this.token = this.fixedToken ?? randomUUID() this.endpointFileWritten = false this.portFallbackApplied = false + try { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: () => undefined, + ingest: (record) => this.ingestSpoolRecord(record) + }) + } catch (err) { + // Why: a downstream relay failure must not prevent the loopback listener from starting; + // the untruncated spool file remains available for retry on the next restart. + process.stderr.write( + `[relay-hook-server] spool replay failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + } try { await this.listenOn(this.preferredPort) } catch (err) { @@ -273,7 +292,8 @@ export class RelayAgentHookServer { event: AgentHookEventPayload, source: AgentHookSource, env?: string, - version?: string + version?: string, + options: { isReplay?: boolean } = {} ): void { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) @@ -294,6 +314,22 @@ export class RelayAgentHookServer { } this.clearPaneState(oldest) } - this.forward(buildRelayHookEnvelope(event, source, env, version)) + this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + } + + private ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = buildSpoolHookBody(record) + const event = normalizeHookPayload(this.state, record.source, body, this.env, { + deferCompactOwnershipToClient: true + }) + if (!event) { + return + } + this.applyEvent(event, record.source, hookBodyEnv(body), hookBodyVersion(body), { + isReplay: true + }) } } diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index c14c1d8a2b1..1131008ca7a 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -137,8 +137,15 @@ export function reconcileRemoteCodexState( if (!lead) { return payload } + // Child lifecycle hooks commonly omit the root prompt. Preserve the last known + // turn label while merging their roster/state so relay restarts do not blank it. + const prompt = + agentId && payload.prompt.length === 0 && previous?.agentType === 'codex' + ? previous.prompt + : payload.prompt return { ...payload, + prompt, state: codexRosterEffectiveState(roster, lead.state), model: lead.model ?? payload.model, subagents: codexRosterToSnapshots(roster) diff --git a/src/shared/agent-hook-spool.ts b/src/shared/agent-hook-spool.ts new file mode 100644 index 00000000000..3ad478ed8a5 --- /dev/null +++ b/src/shared/agent-hook-spool.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { + closeSync, + fstatSync, + ftruncateSync, + openSync, + readFileSync, + readSync, + readdirSync, + statSync, + writeSync +} from 'node:fs' +import { join } from 'node:path' + +export const AGENT_HOOK_SPOOL_MAX_BYTES = 5 * 1024 * 1024 +export const AGENT_HOOK_SPOOL_MAX_FILES = 1024 +export const AGENT_HOOK_SPOOL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +export type SpoolRecord = { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hookEventName?: string + source: string + payload: unknown + receivedAt: number + [key: string]: unknown +} + +/** Restore the HTTP listener body shape from a shell-written spool record. */ +export function buildSpoolHookBody(record: SpoolRecord): Record { + return { + paneKey: record.paneKey, + tabId: record.tabId, + worktreeId: record.worktreeId, + env: record.env, + version: record.version, + launchToken: record.launchToken, + hookEventName: record.hookEventName, + payload: record.payload + } +} + +export function launchTokenHash(token: string | undefined): string | null { + return token?.trim() ? createHash('sha256').update(token.trim()).digest('hex') : null +} + +export function readSpoolRecords(path: string, now = Date.now()): SpoolRecord[] { + return readSpoolFile(path, now).records +} + +/** Records plus the byte offset through the last COMPLETE line. A torn trailing line is + * left unconsumed so a writer still finishing it is not truncated away. */ +export function readSpoolFile( + path: string, + now = Date.now() +): { records: SpoolRecord[]; consumed: number } { + let bytes: Buffer + try { + bytes = readFileSync(path) + } catch { + return { records: [], consumed: 0 } + } + const records: SpoolRecord[] = [] + let consumed = 0 + let start = 0 + for (let end = 0; end <= bytes.length; end += 1) { + if (end !== bytes.length && bytes[end] !== 0x0a) { + continue + } + // A final line without its newline may still be in flight from a hook writer. + // Leave it untouched until the writer terminates the record explicitly. + if (end === bytes.length && (end === 0 || bytes[end - 1] !== 0x0a)) { + break + } + const lineBytes = bytes.subarray(start, end) + if (end !== bytes.length) { + consumed = end + 1 + } + start = end + 1 + if (lineBytes.length === 0) { + continue + } + try { + const value = JSON.parse(lineBytes.toString('utf8')) as Partial + if ( + typeof value.paneKey === 'string' && + typeof value.source === 'string' && + value.payload !== undefined && + typeof value.receivedAt === 'number' && + value.receivedAt >= now - AGENT_HOOK_SPOOL_MAX_AGE_MS + ) { + records.push(value as SpoolRecord) + } + } catch { + // Torn lines are discarded while later complete lines remain replayable. + } + } + return { records, consumed } +} + +export type SpoolDrainOptions = { + endpointDir: string + getPersistedLaunchTokenHash: (paneKey: string) => string | undefined + ingest: (record: SpoolRecord) => void + now?: number +} + +/** Drain JSONL files in place; never replace or unlink an inode held by a hook writer. */ +export function drainAgentHookSpool(options: SpoolDrainOptions): number { + const spoolDir = join(options.endpointDir, 'spool') + let names: string[] + try { + names = readdirSync(spoolDir) + } catch { + return 0 + } + const now = options.now ?? Date.now() + const candidates = names + .map((name) => { + const path = join(spoolDir, name) + try { + const stat = statSync(path) + // Empty files are retained to keep append handles race-safe, but they must not + // consume the bounded replay candidate set ahead of files with durable events. + return stat.isFile() && stat.size > 0 ? { path, mtimeMs: stat.mtimeMs } : null + } catch { + return null + } + }) + .filter((entry): entry is { path: string; mtimeMs: number } => entry !== null) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + .slice(0, AGENT_HOOK_SPOOL_MAX_FILES) + let drained = 0 + for (const candidate of candidates) { + const { records, consumed } = readSpoolFile(candidate.path, now) + for (const record of records) { + const expected = options.getPersistedLaunchTokenHash(record.paneKey) + const actual = launchTokenHash(record.launchToken) + if (expected && actual !== expected) { + continue + } + options.ingest({ ...record, isReplay: true }) + drained += 1 + } + try { + const fd = openSync(candidate.path, 'r+') + try { + // Keep the inode so a concurrent append handle cannot silently orphan writes. + const size = fstatSync(fd).size + if (size > consumed) { + // Why: a hook may have appended between the read and here; shift the unread tail + // to the front instead of truncating to zero, which would erase it. + const tail = Buffer.alloc(size - consumed) + readSync(fd, tail, 0, tail.length, consumed) + writeSync(fd, tail, 0, tail.length, 0) + ftruncateSync(fd, tail.length) + } else { + ftruncateSync(fd, 0) + } + } finally { + closeSync(fd) + } + } catch { + // A concurrently removed or inaccessible file is harmless; the next launch retries it. + } + } + return drained +} From 8d0156ae0b225fe0d5e489c5eac1fd8063efb950 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:30:30 -0700 Subject: [PATCH 7/9] fix(terminal): route Enter to focused split pane (#16705) * fix(terminal): route shortcuts to focused split pane * fix(terminal): synchronize IME fallback pane --- ...keyboard-handlers-ime-enter-keyup.test.tsx | 51 +++++++++- ...inal-keyboard-event-handlers-focus.test.ts | 94 +++++++++++++++++++ .../terminal-keyboard-event-handlers.ts | 14 ++- .../terminal-keyboard-pane-resolution.test.ts | 64 +++++++++++++ .../terminal-keyboard-pane-resolution.ts | 28 ++++++ .../terminal-keyboard-release-handlers.ts | 3 +- 6 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx index 2d4f179c753..88396a6be87 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx @@ -28,9 +28,10 @@ function keyboardEvent( return event } -function createHarness(): { +function createHarness(options: { staleActivePane?: boolean } = {}): { deps: KeyboardHandlersDeps sendInput: ReturnType + setActivePane: ReturnType startComposition: () => void terminalInput: HTMLTextAreaElement dispose: () => void @@ -40,7 +41,8 @@ function createHarness(): { const terminalInput = document.createElement('textarea') terminalInput.className = 'xterm-helper-textarea' terminalElement.append(terminalInput) - scope.append(terminalElement) + const staleTerminalElement = document.createElement('div') + scope.append(staleTerminalElement, terminalElement) document.body.append(scope) const sendInput = vi.fn(() => true) @@ -49,7 +51,7 @@ function createHarness(): { sendInput } as unknown as PtyTransport const pane = { - id: 1, + id: options.staleActivePane ? 2 : 1, leafId: '00000000-0000-4000-8000-000000000001', terminal: { element: terminalElement, @@ -57,9 +59,24 @@ function createHarness(): { getSelection: vi.fn(() => '') } } + const stalePane = { + id: 1, + leafId: '00000000-0000-4000-8000-000000000002', + terminal: { + element: staleTerminalElement, + focus: vi.fn(), + getSelection: vi.fn(() => '') + } + } + let activePane = options.staleActivePane ? stalePane : pane + const panes = options.staleActivePane ? [stalePane, pane] : [pane] + const setActivePane = vi.fn((paneId: number) => { + activePane = panes.find((candidate) => candidate.id === paneId) ?? activePane + }) const manager = { - getActivePane: () => pane, - getPanes: () => [pane] + getActivePane: () => activePane, + getPanes: () => panes, + setActivePane } as unknown as PaneManager const route = installTerminalImeCompositionRoute({ terminalElement, @@ -96,6 +113,7 @@ function createHarness(): { return { deps, sendInput, + setActivePane, terminalInput, startComposition: () => { terminalElement.dispatchEvent( @@ -337,6 +355,29 @@ describe('Windows IME Enter-keyup press-time evidence', () => { harness.dispose() }) + it('routes a swallowed Enter keydown fallback to the focused pane and repairs stale active state', () => { + const harness = createHarness({ staleActivePane: true }) + const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) + harness.startComposition() + + harness.terminalInput.dispatchEvent( + keyboardEvent('keyup', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + timeStamp: 30, + shiftKey: true + }) + ) + vi.runAllTimers() + + expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false }) + expect(harness.sendInput).toHaveBeenCalledOnce() + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + hook.unmount() + harness.dispose() + }) + it('does not send a second newline from the keyup of a directly-sent Shift+Enter', () => { const harness = createHarness() const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps)) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts new file mode 100644 index 00000000000..bc1e883647b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { createTerminalKeyboardEventHandlers } from './terminal-keyboard-event-handlers' + +describe('terminal keyboard pane ownership', () => { + it('submits Enter through the helper textarea pane when active state is stale', () => { + const scope = document.createElement('div') + const firstElement = document.createElement('div') + const focusedElement = document.createElement('div') + const focusedInput = document.createElement('textarea') + focusedInput.className = 'xterm-helper-textarea' + focusedElement.appendChild(focusedInput) + scope.append(firstElement, focusedElement) + document.body.appendChild(scope) + + const first = { id: 1, leafId: 'leaf-1', terminal: { element: firstElement } } + const focused = { id: 2, leafId: 'leaf-2', terminal: { element: focusedElement } } + let active = first + const setActivePane = vi.fn((paneId: number) => { + active = paneId === focused.id ? focused : first + }) + const sendFocused = vi.fn() + const handlers = createTerminalKeyboardEventHandlers({ + isMac: false, + isWindows: false, + shortcutPlatform: 'linux', + keyboardScopeRef: { current: scope }, + resolveShortcutEvent: () => ({ type: 'sendInput', data: '\r' }), + createCapturedInputSender: (pane) => (pane.id === focused.id ? sendFocused : vi.fn()), + nativeOnlyShortcutTracker: { + prepareKeyDown: vi.fn(), + armKeyDown: vi.fn() + }, + observedEnterKeydownTimeStamps: new Map(), + modifiedEnterChordOwner: { + ownsRedispatchedEnter: () => false, + absorb: () => false, + claim: () => true + }, + deferredNewlineSender: { + absorbRedispatchedEnter: () => false, + defer: vi.fn() + }, + deferredChordSender: { defer: vi.fn() }, + getModifiedEnterChord: () => null, + reconcileHeldImeEnterModifiers: vi.fn(), + optionKittyReleases: { arm: vi.fn(), armNativeDeadKey: vi.fn() }, + terminalImeEnterModifierKeydowns: new Set(), + paneKittyKeyboardModesRef: { current: new Map() }, + managerRef: { + current: { + getActivePane: () => active, + getPanes: () => [first, focused], + setActivePane + } + }, + paneTransportsRef: { current: new Map() }, + panePtyBindingsRef: { current: new Map() }, + paneCwdRef: { current: new Map() }, + tabId: 'tab-1', + worktreeId: 'worktree-1', + fallbackCwd: '', + expandedPaneIdRef: { current: null }, + setExpandedPane: vi.fn(), + restoreExpandedLayout: vi.fn(), + refreshPaneSizes: vi.fn(), + persistLayoutSnapshot: vi.fn(), + toggleExpandPane: vi.fn(), + setSearchOpen: vi.fn(), + onSearchSelectedText: vi.fn(), + onRequestClosePane: vi.fn(), + onClearPaneScrollback: vi.fn(), + onSetTitle: vi.fn(), + onClearPaneTitle: vi.fn(), + searchOpenRef: { current: false }, + searchStateRef: { current: { query: '', caseSensitive: false, regex: false } }, + keybindings: undefined, + terminalShortcutPolicy: 'orca-first', + getKeyboardSplitTelemetrySource: () => 'keyboard' + } as never) + + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter' + }) + Object.defineProperty(event, 'keyCode', { value: 13 }) + focusedInput.dispatchEvent(event) + handlers.onKeyDown(event) + + expect(setActivePane).toHaveBeenCalledWith(2, { focus: false }) + expect(sendFocused).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts index c711ff747a4..a66fbf96fe4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts @@ -18,6 +18,7 @@ import { import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispatch' import { getLayoutCharacterForCode } from '@/lib/keyboard-layout/layout-base-character' import { createTerminalKeyboardReleaseHandlers } from './terminal-keyboard-release-handlers' +import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution' const MAX_OBSERVED_ENTER_KEYDOWNS_PER_CODE = 8 @@ -111,6 +112,11 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { return } + // The browser's focused xterm helper is the input owner. A split can retain + // a stale manager activePaneId after focus moved, so repair it before any + // shortcut policy reads pane-scoped host/agent state. + const keyboardPane = synchronizeTerminalKeyboardPane(manager, e.target) + const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null if ( e.key === 'Enter' && @@ -127,7 +133,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { return } - const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0] + const terminalPaneForImeShortcut = keyboardPane const hasPendingImeComposition = hasPendingTerminalImeComposition( terminalPaneForImeShortcut?.terminal.element ) @@ -139,7 +145,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { } if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) { - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection()) if (selectedText) { e.preventDefault() @@ -160,7 +166,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { } e.preventDefault() e.stopImmediatePropagation() - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane if (!pane) { return } @@ -210,7 +216,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { if (action.type === 'sendInput') { e.preventDefault() e.stopImmediatePropagation() - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = keyboardPane if (!pane) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts new file mode 100644 index 00000000000..3876f2099e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { + resolveTerminalKeyboardPane, + synchronizeTerminalKeyboardPane +} from './terminal-keyboard-pane-resolution' + +function pane(id: number): { + id: number + leafId: string + terminal: { element: HTMLElement } +} { + return { + id, + leafId: `leaf-${id}`, + terminal: { element: document.createElement('div') } + } +} + +describe('resolveTerminalKeyboardPane', () => { + it('uses the pane containing the event target when manager active state is stale', () => { + const active = pane(1) + const focused = pane(2) + const input = document.createElement('textarea') + focused.terminal.element.appendChild(input) + const manager = { + getActivePane: vi.fn(() => active), + getPanes: vi.fn(() => [active, focused]) + } + + expect(resolveTerminalKeyboardPane(manager as never, input)).toBe(focused) + expect(manager.getActivePane).not.toHaveBeenCalled() + }) + + it('falls back to the manager active pane for non-terminal shortcuts', () => { + const active = pane(1) + const manager = { + getActivePane: vi.fn(() => active), + getPanes: vi.fn(() => [active]) + } + + expect(resolveTerminalKeyboardPane(manager as never, document.body)).toBe(active) + }) + + it('repairs stale active state without stealing DOM focus', () => { + const active = pane(1) + const focused = pane(2) + const input = document.createElement('textarea') + focused.terminal.element.appendChild(input) + let activePane = active + const setActivePane = vi.fn((id: number) => { + activePane = id === focused.id ? focused : active + }) + const manager = { + getActivePane: vi.fn(() => activePane), + getPanes: vi.fn(() => [active, focused]), + setActivePane + } + + expect(synchronizeTerminalKeyboardPane(manager as never, input)).toBe(focused) + expect(setActivePane).toHaveBeenCalledWith(focused.id, { focus: false }) + expect(activePane).toBe(focused) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts new file mode 100644 index 00000000000..643b0439741 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.ts @@ -0,0 +1,28 @@ +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' + +/** Resolve keyboard ownership from the focused xterm surface before using manager state. */ +export function resolveTerminalKeyboardPane( + manager: Pick, + target: EventTarget | null +): ManagedPane | null { + if (typeof Node !== 'undefined' && target instanceof Node) { + const focusedPane = manager + .getPanes() + .find((pane) => pane.terminal.element?.contains(target) === true) + if (focusedPane) { + return focusedPane + } + } + return manager.getActivePane() ?? manager.getPanes()[0] ?? null +} + +export function synchronizeTerminalKeyboardPane( + manager: Pick, + target: EventTarget | null +): ManagedPane | null { + const pane = resolveTerminalKeyboardPane(manager, target) + if (pane && manager.getActivePane()?.id !== pane.id) { + manager.setActivePane(pane.id, { focus: false }) + } + return pane +} diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts index 2c96347ad00..095c1e33b77 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-release-handlers.ts @@ -4,6 +4,7 @@ import { isTerminalImeEnterKeyUp } from './terminal-ime-deferred-newline' import { isEditableTarget } from './terminal-keyboard-shortcut-matching' import { hasPendingTerminalImeComposition } from './terminal-ime-composition-route' import { keyboardEventBelongsToScope } from './terminal-keyboard-scope' +import { synchronizeTerminalKeyboardPane } from './terminal-keyboard-pane-resolution' type Runtime = ReturnType type ReleaseContext = KeyboardHandlersDeps & @@ -101,7 +102,7 @@ export function createTerminalKeyboardReleaseHandlers(context: ReleaseContextWit !isEditableTarget(e.target) && (!scope || keyboardEventBelongsToScope(e, scope)) ) { - const pane = manager.getActivePane() ?? manager.getPanes()[0] + const pane = synchronizeTerminalKeyboardPane(manager, e.target) if (pane && hasPendingTerminalImeComposition(pane.terminal.element)) { const action = resolveShortcutEvent({ key: 'Enter', From de6fe8b7eabd220d100e4cc652413461d444d2ba Mon Sep 17 00:00:00 2001 From: Lesley Murfin Date: Thu, 27 Aug 2026 00:35:18 -0600 Subject: [PATCH 8/9] fix(worktrees): resolve id: worktree selectors by path equivalence (#16243) (#16494) * test(worktrees): cover id: selector path-spelling parity with path: (#16243) The renderer can only address a workspace by id (toRuntimeWorktreeSelector always emits id:::), and the runtime matches that id byte for byte while a path: selector has always compared through normalizeRuntimePathForComparison. A stored id that spells its path differently from `git worktree list` therefore resolves for the CLI and answers selector_not_found for the UI, which reads that as a stale local mirror, calls forgetLocal, reports success, and lets the row return on the next catalog refresh: a silent delete. These tests fail on both resolution sites -- the fleet `id:` branch of resolveWorktreeSelector and the scoped resolveScopedWorktreeIdRow a host-qualified removal takes -- and pin what must stay closed: an exact repo id (STA-4343), host qualification, dot segments neither selector canonicalizes, and a refusal rather than a guess when two rows spell one path. 13 failing, 35 passing. * fix(worktrees): resolve id: worktree selectors by path equivalence (#16243) worktreeIdComparisonKey names one repo, one filesystem location, and one folder-workspace instance, folding exactly the path spellings normalizeRuntimePathForComparison already folds for a path: selector -- and nothing more, so dot segments stay unresolved for both shapes. Both id: resolution sites consult it only after an exact match finds nothing: the fleet branch of resolveWorktreeSelector and resolveScopedWorktreeIdRow, which a host-qualified removal takes. runtimeWorktreeIdsEqual now derives from the same key so the runtime has one normalizer rather than a parallel one. Not a pure refactor at that last site: runtimeWorktreeIdsEqual used to normalize-compare ids that parse but carry an empty repoId or an empty path ('::/p', or 'repo::' against 'repo::/'), and worktreeIdComparisonKey returns null for those, so across its call sites (PTY identity, refresh, mutation queue) such ids now compare byte-exact instead. That narrows matching rather than widening it, no real worktree carries such an id, and it is the behavior #15616 guarantees for malformed ids -- but it is a behavior delta, not just a tidy-up. Perf (#14399): the exact match is still tried first and still wins outright, so a resolvable id costs exactly what it did before. Neither site adds a scan -- the fleet branch re-filters the array it had already listed, the scoped lookup re-filters the single owning repo's projected rows -- so an explicit id still never scans every repo. Fail-closed behavior is unchanged: the repo id compares exactly (STA-4343), host qualification is untouched, the folder-workspace instance suffix stays part of the path, and a scoped lookup with two equivalent rows refuses instead of guessing. The bare unprefixed selector branch keeps byte-exact id matching, since only the id: shape reaches a renderer caller. Shares src/shared/worktree/id.ts with the open #15616, which introduces worktreeIdComparisonKey for the same divergence in lineage pruning and authoritative-scan purging; this adopts that helper rather than adding a second one. Complementary to the open #16295, which makes the miss visible; this removes the miss. * chore(worktrees): satisfy oxfmt and oxlint on #16243 tests oxfmt --check flagged both new test files and oxlint's unicorn/no-useless-fallback-in-spread flagged the store mock; the full lint and format gates now match the pre-change baseline. * test(worktrees): pin Windows spellings and malformed-id exactness (#16243) Review found two axes the first pass left unproven at the two id: resolution sites. Both are the invariants the open #15616 guarantees for the shared worktreeIdComparisonKey it introduces for #15598, so violating either here would break a contract a sibling PR depends on. Windows: #15598's whole defect is that one checkout is recorded under both `D:\Agentic\game2` and `D:/Agentic/game2`. The fleet branch, the scoped removal lookup, and the key itself now each resolve the backslash spelling against the forward-slash spelling git reports, and fold drive-letter case -- while a backslash inside a POSIX path stays a filename character and a POSIX root stays case-sensitive, exactly as normalizeRuntimePathForComparison already decides for a path: selector. Malformed ids keep exact matching at both sites: an id with no repo boundary or an empty path still refuses, and the scoped lookup still refuses it without scanning. Four of these fail without the production change (three fleet/removal Windows cases and the scoped one); the malformed-id and POSIX-backslash cases are invariant guards that hold either way. Verified: 58 passed in the three files; 18 fail with the production hunks reverted; orca-runtime.test.ts and worktree-teardown-unstopped-pty.test.ts green (1270 passed | 1 skipped); pnpm tc:node clean. * test(worktrees): pin Windows id: spelling folds and fleet ambiguity refusal (#16243) The Windows backslash spelling now rides the ID_SPELLINGS rows, so it is driven through both id: sites -- resolveWorktreeSelector and the scoped removal target -- and compared against what the same workspace's path: selector resolves, rather than only through worktreeIdComparisonKey. That is the spelling #15598/#15616 found in the wild and the one the owner's Windows client produces. The fleet path's ambiguity refusal had no test: two same-repo rows spelling one directory, an id: matching neither exactly, must reject selector_ambiguous. It is the fail-closed guard on a delete-capable resolver, and the property a later refactor is most likely to turn into a silent pick. Also records two limits at the source instead of leaving them to be rediscovered: a UNC or WSL root never folds into a drive-letter location (while Windows' two WSL UNC aliases do name one location), and a folder-workspace id keeps a trailing slash placed before the ::workspace: suffix, so that spelling stays exact-match-only. Neither behavior changes here. The file docblock overclaimed parity. path: collapses duplicate same-host registrations to the first row while a folded id: refuses them; the contract this file pins is path-spelling parity, not dedup parity, and the divergence is deliberate because this resolver also serves delete. Non-vacuity, verified by temporarily reverting the production hunks: neutralizing both id: fallbacks turns 10 of these tests red, including both new Windows rows and the ambiguity refusal (it degrades to selector_not_found). Making the fleet fallback pick the first folded match instead of collecting all of them turns the ambiguity test red on its own. The remaining cases -- malformed ids, dot segments, the POSIX backslash, the folder-workspace slash -- pass against the pre-fix code too: they guard against future widening rather than proving this fix. Drops the two Windows cases the ID_SPELLINGS row subsumes. The drive-letter case test asserted only the Windows half its name promised; it now also pins that a POSIX root does NOT fold case, since an unconditional lowercase would merge /data/Foo with /data/foo on the platform CI runs on. Fixture paths use the upstream-attested /srv/projects prefix (and a neutral plugin-host leaf) instead of a local install root; the spelling variations the tests exist to pin -- doubled separator, dot segment, trailing slash, uppercase POSIX, cafe NFC/NFD, and the Windows D: rows -- are unchanged in form. * docs(worktrees): trim the id: selector test header and document the comparison key (#16243) --- src/main/runtime/orca-runtime.ts | 22 +- .../repo-worktree-row-resolution.test.ts | 85 ++++++ .../runtime/repo-worktree-row-resolution.ts | 21 +- ...ktree-rm-id-selector-path-spelling.test.ts | 273 ++++++++++++++++++ src/shared/worktree/id.test.ts | 65 ++++- src/shared/worktree/id.ts | 17 ++ 6 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c33d741bc29..ee1f977ca88 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -534,7 +534,8 @@ import { WORKTREE_ID_SEPARATOR, getRepoIdFromWorktreeId, splitWorktreeId, - splitWorktreeIdForFilesystem + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey } from '../../shared/worktree/id' import { getProjectIdForProviderIdentity, @@ -32294,6 +32295,7 @@ export class OrcaRuntimeService { return worktreeId } + /** Resolves one workspace or throws `selector_not_found` / `selector_ambiguous` — never picks a winner. */ private async resolveWorktreeSelector(selector: string): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) // Why only `id:`: every other selector kind is matched across the whole fleet, and their @@ -32315,6 +32317,16 @@ export class OrcaRuntimeService { if (selector.startsWith('id:')) { const worktreeId = explicitWorktreeId ?? selector.slice(3) candidates = worktrees.filter((worktree) => worktree.id === worktreeId) + if (candidates.length === 0) { + // Why (#16243): `id:` is the only shape the renderer can send, and a stored id can spell + // its path differently from the scan — the divergence `path:` has always absorbed. + // The bare unprefixed branch below stays byte-exact on purpose: only `id:` reaches a + // renderer caller, so `id:repo::p/` folds here while bare `repo::p/` still misses. + const comparisonKey = worktreeIdComparisonKey(worktreeId) + candidates = comparisonKey + ? worktrees.filter((worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey) + : candidates + } if (candidates.length === 0) { const parsed = splitWorktreeIdForFilesystem(worktreeId) const repo = parsed ? this.store?.getRepo(parsed.repoId) : null @@ -41428,12 +41440,8 @@ function runtimePathsEqual(left: string, right: string): boolean { * Windows/WSL/SSH ids still match themselves across hosts. */ function runtimeWorktreeIdsEqual(left: string, right: string): boolean { - const parsedLeft = splitWorktreeId(left) - const parsedRight = splitWorktreeId(right) - return parsedLeft && parsedRight - ? parsedLeft.repoId === parsedRight.repoId && - runtimePathsEqual(parsedLeft.worktreePath, parsedRight.worktreePath) - : left === right + const leftKey = worktreeIdComparisonKey(left) + return leftKey === null ? left === right : leftKey === worktreeIdComparisonKey(right) } function runtimeWorktreeIdentityKey(worktreeId: string): string { diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index be50f5678a7..d4bff4a9442 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -200,3 +200,88 @@ describe('host-qualified scoped worktree resolution', () => { expect(getRepos).not.toHaveBeenCalled() }) }) + +/** + * #16243: the renderer can only address a workspace by `id:::`, and this scoped + * lookup is what a host-qualified removal resolves through. It matched the id byte for byte while a + * `path:` selector has always compared through `normalizeRuntimePathForComparison`, so a stored id + * spelling its path differently from `git worktree list` resolved for the CLI and not for the UI. + */ +describe('scoped worktree id resolution across path spellings (#16243)', () => { + it.each([ + ['a trailing slash', '/same/worktree', 'shared::/same/worktree/'], + ['a doubled separator', '/same/worktree', 'shared::/same//worktree'], + ['an NFD name', '/same/café', `shared::${'/same/café'.normalize('NFD')}`] + ])('resolves the scanned row when the id carries %s', async (_label, scannedPath, worktreeId) => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree(scannedPath)] + })) + + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toMatchObject({ + id: `shared::${scannedPath}`, + path: scannedPath + }) + }) + + it('still refuses the same path under a different repo id', async () => { + const deps = createDeps([ + repo('shared', '/local/repo', { executionHostId: 'local' }), + repo('unrelated', '/unrelated/repo', { executionHostId: 'local' }) + ]) + + await expect( + resolveScopedWorktreeIdRow(deps, 'unrelated::/same/worktree/', 'local') + ).resolves.toBeNull() + }) + + it('refuses rather than guessing when two rows spell one path', async () => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree('/same/worktree'), gitWorktree('/same//worktree')] + })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::/same/worktree/', 'local') + ).resolves.toBeNull() + }) + + it('prefers the exactly matching row over an equivalent spelling', async () => { + const owner = repo('shared', '/local/repo', { executionHostId: 'local' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ + ok: true, + worktrees: [gitWorktree('/same//worktree'), gitWorktree('/same/worktree')] + })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::/same//worktree', 'local') + ).resolves.toMatchObject({ id: 'shared::/same//worktree' }) + }) + + // #15598/#15616: the backslash spelling is what a pre-restart Windows registration recorded. + it('resolves a Windows backslash id against the forward-slash spelling git reports', async () => { + const path = 'D:/Agentic/game2/battle-core' + const owner = repo('shared', 'D:/Agentic/game2', { executionHostId: 'runtime:windows' }) + const deps = createDeps([owner]) + deps.scanRepo.mockImplementation(async () => ({ ok: true, worktrees: [gitWorktree(path)] })) + + await expect( + resolveScopedWorktreeIdRow(deps, 'shared::D:\\Agentic\\game2\\battle-core', 'runtime:windows') + ).resolves.toMatchObject({ id: `shared::${path}`, path }) + }) + + it.each([ + ['no repo boundary', 'not-an-id'], + ['an empty path', 'shared::'] + ])('keeps exact matching for a malformed id with %s', async (_label, worktreeId) => { + const deps = createDeps([repo('shared', '/local/repo', { executionHostId: 'local' })]) + + await expect(resolveScopedWorktreeIdRow(deps, worktreeId, 'local')).resolves.toBeNull() + expect(deps.scanRepo).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index b65fd9295f6..a9ef618ced7 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -1,4 +1,8 @@ -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { + splitWorktreeId, + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey +} from '../../shared/worktree/id' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { isFolderRepo } from '../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' @@ -195,5 +199,18 @@ export async function resolveScopedWorktreeIdRow( resolveLocalProjectRuntimesForRepos(store, [repo]) ) const projected = projectResolvedWorktreeLineage(rows, store.getAllWorktreeLineage?.() ?? {}) - return projected.find((worktree) => worktree.id === worktreeId) ?? null + const exact = projected.find((worktree) => worktree.id === worktreeId) + if (exact) { + return exact + } + // Why (#16243): the scan can spell this id's path differently — the divergence `path:` absorbs. + // One equivalent row may stand in; two is an ambiguity a scoped lookup must refuse, not guess. + const comparisonKey = worktreeIdComparisonKey(worktreeId) + if (comparisonKey === null) { + return null + } + const equivalent = projected.filter( + (worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey + ) + return equivalent.length === 1 ? equivalent[0] : null } diff --git a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts new file mode 100644 index 00000000000..f4e61a76381 --- /dev/null +++ b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts @@ -0,0 +1,273 @@ +/** + * Pins path-SPELLING parity between an `id:` selector and `path:`, deliberately not dedupe parity: + * where two same-host rows spell one directory, `path:` collapses them to the first while `id:` + * refuses as ambiguous, because this resolver also serves delete (#16243). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0), + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', + requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) + +const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) +vi.mock('../git/worktree', async (importOriginal) => ({ + ...(await importOriginal>()), + listWorktreesStrict: listWorktreesStrictMock +})) + +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { OrcaRuntimeService } from './orca-runtime' + +const REPO_ID = 'repo-local' +const REPO_PATH = '/srv/projects/app' +/** The spelling `git worktree list` reports. */ +const WORKTREE_PATH = '/srv/projects/workspaces/plugin-host' +const CANONICAL_ID = `${REPO_ID}::${WORKTREE_PATH}` + +/** One directory, other spellings a stored id can legitimately carry. */ +const ID_SPELLINGS: [label: string, worktreePath: string, repoPath?: string][] = [ + ['a trailing slash', `${WORKTREE_PATH}/`], + ['a doubled separator', '/srv/projects//workspaces/plugin-host'], + ['an NFD workspace name', '/srv/projects/workspaces/café-plugin'.normalize('NFD')], + // #15598/#15616: a Windows registration records backslashes; git reports forward slashes. + ['a backslash Windows spelling', 'D:\\Agentic\\game2\\battle-core', 'D:/Agentic/game2'] +] + +/** What a scan reports for a stored id: the same directory, canonically spelled. */ +function scannedSpellingOf(storedPath: string): string { + const slashed = /^[A-Za-z]:[\\/]/.test(storedPath) ? storedPath.replace(/\\/g, '/') : storedPath + return slashed.normalize('NFC').replace(/\/+/g, '/').replace(/\/$/, '') +} + +/** One registered repo whose worktree meta is writable, so a delete's `forgetLocal` is observable. */ +function makeStore(repoPath: string = REPO_PATH) { + const metaById: Record> = {} + const store = { + getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), + getRepos: () => [ + { id: REPO_ID, path: repoPath, displayName: 'app', badgeColor: 'blue', addedAt: 1 } + ], + getAllWorktreeMeta: vi.fn(() => metaById), + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...metaById[id], ...meta } + return metaById[id] + }, + removeWorktreeMeta: () => {}, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } + return store +} + +/** `git worktree list` output: the main checkout plus one workspace at `worktreePath`. */ +function scanReports(worktreePath: string, repoPath: string = REPO_PATH): void { + listWorktreesStrictMock.mockResolvedValue([ + { path: repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: worktreePath, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } + ]) +} + +type RemovalInternals = { + resolveWorktreeRemovalTarget: ( + worktreeSelector: string, + requiredHostId?: string + ) => Promise<{ id: string; repoId: string; path: string }> +} + +beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() +}) + +describe('worktree id selectors vs. the path spelling git reports (#16243)', () => { + it.each(ID_SPELLINGS)( + 'resolves through the fleet path what `path:` resolves when the id carries %s', + async (_label, storedPath, repoPath) => { + scanReports(scannedSpellingOf(storedPath), repoPath) + const runtime = new OrcaRuntimeService(makeStore(repoPath) as never) + + // The CLI's shape, and the live data point: `path:` already resolves it. + const byPath = await runtime.showManagedWorktree(`path:${storedPath}`) + // The only shape the renderer can send must resolve the SAME workspace. + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::${storedPath}`) + ).resolves.toMatchObject({ id: byPath.id, path: byPath.path }) + } + ) + + it.each(ID_SPELLINGS)( + 'resolves a host-qualified removal target when the id carries %s', + async (_label, storedPath, repoPath) => { + scanReports(scannedSpellingOf(storedPath), repoPath) + const runtime = new OrcaRuntimeService(makeStore(repoPath) as never) + const internals = runtime as unknown as RemovalInternals + + // The scoped path the UI's delete takes must agree with the fleet path above. + await expect( + internals.resolveWorktreeRemovalTarget( + `id:${REPO_ID}::${storedPath}`, + LOCAL_EXECUTION_HOST_ID + ) + ).resolves.toMatchObject({ repoId: REPO_ID, path: scannedSpellingOf(storedPath) }) + } + ) + + it('still refuses an id whose path names a different workspace', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects/workspaces/other-plugin`) + ).rejects.toThrow('selector_not_found') + }) + + // STA-4343: matching across repo ids would delete a workspace the caller never confirmed. + it('still refuses the same path under a different repo id', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect(runtime.showManagedWorktree(`id:other-repo::${WORKTREE_PATH}/`)).rejects.toThrow( + 'selector_not_found' + ) + }) + + it('still refuses a removal qualified to a host that does not own the repo id', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const internals = runtime as unknown as RemovalInternals + + await expect( + internals.resolveWorktreeRemovalTarget(`id:${CANONICAL_ID}/`, 'runtime:env-b') + ).rejects.toThrow('selector_not_found') + }) + + it('keeps `id:` and `path:` agreeing on a dot segment neither canonicalizes', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const dotted = '/srv/projects/./workspaces/plugin-host' + + await expect(runtime.showManagedWorktree(`path:${dotted}`)).rejects.toThrow( + 'selector_not_found' + ) + await expect(runtime.showManagedWorktree(`id:${REPO_ID}::${dotted}`)).rejects.toThrow( + 'selector_not_found' + ) + }) + + // #15598/#15616: on Windows one checkout is recorded under both spellings, and git reports the + // forward-slash one. The backslash id itself rides the ID_SPELLINGS rows above; these pin the + // folding limits around it. + describe('Windows drive-letter spellings', () => { + const WINDOWS_REPO = 'D:/Agentic/game2' + const WINDOWS_WORKTREE = 'D:/Agentic/game2/battle-core' + + it('folds drive-letter case only for Windows paths, never for a POSIX path', async () => { + scanReports(WINDOWS_WORKTREE, WINDOWS_REPO) + const windowsRuntime = new OrcaRuntimeService(makeStore(WINDOWS_REPO) as never) + + // A Windows root is case-insensitive, as `path:` already treats it. + await expect( + windowsRuntime.showManagedWorktree(`id:${REPO_ID}::d:/agentic/game2/battle-core`) + ).resolves.toMatchObject({ path: WINDOWS_WORKTREE }) + + // A POSIX root is not: folding case there would merge distinct directories for a delete. + scanReports(WORKTREE_PATH) + const posixRuntime = new OrcaRuntimeService(makeStore() as never) + + await expect( + posixRuntime.showManagedWorktree(`id:${REPO_ID}::/SRV/projects/workspaces/plugin-host`) + ).rejects.toThrow('selector_not_found') + }) + + it('does not fold a backslash inside a POSIX path, where it is a valid filename character', async () => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::/srv/projects\\workspaces\\plugin-host`) + ).rejects.toThrow('selector_not_found') + }) + }) + + // The fail-closed guard on a delete-capable resolver: two rows spelling one directory must not + // let a folded id pick one. `path:` collapses same-host duplicates; `id:` deliberately refuses. + it('refuses a folded id when two same-repo rows spell one directory', async () => { + listWorktreesStrictMock.mockResolvedValue([ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false }, + { + path: '/srv/projects//workspaces/plugin-host', + head: 'ghi', + branch: 'feature-2', + isBare: false, + isMainWorktree: false + } + ]) + const runtime = new OrcaRuntimeService(makeStore() as never) + + // Matches neither row exactly, folds to both. + await expect(runtime.showManagedWorktree(`id:${CANONICAL_ID}/`)).rejects.toThrow( + 'selector_ambiguous' + ) + }) + + // Live-proof limit, pinned so nobody "fixes" the trimming: a folder-workspace id only trims a + // trailing slash at end of string, so one placed before the instance suffix stays exact-only. + it('keeps a folder-workspace id exact when a slash precedes the instance suffix', async () => { + const instanceSuffix = '::workspace:123e4567-e89b-12d3-a456-426614174000' + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + + await expect( + runtime.showManagedWorktree(`id:${REPO_ID}::${WORKTREE_PATH}/${instanceSuffix}`) + ).rejects.toThrow('selector_not_found') + }) + + // #15616 guarantees malformed ids keep exact-match behavior; both sites must honour that too. + it.each([ + ['no repo boundary', 'not-an-id'], + ['an empty path', `${REPO_ID}::`] + ])('keeps exact matching for a malformed id with %s', async (_label, malformedId) => { + scanReports(WORKTREE_PATH) + const runtime = new OrcaRuntimeService(makeStore() as never) + const internals = runtime as unknown as RemovalInternals + + await expect(runtime.showManagedWorktree(`id:${malformedId}`)).rejects.toThrow( + 'selector_not_found' + ) + await expect( + internals.resolveWorktreeRemovalTarget(`id:${malformedId}`, LOCAL_EXECUTION_HOST_ID) + ).rejects.toThrow('selector_not_found') + }) +}) diff --git a/src/shared/worktree/id.test.ts b/src/shared/worktree/id.test.ts index e64d27784f5..726e53abe5a 100644 --- a/src/shared/worktree/id.test.ts +++ b/src/shared/worktree/id.test.ts @@ -4,7 +4,8 @@ import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId, splitWorktreeId, - splitWorktreeIdForFilesystem + splitWorktreeIdForFilesystem, + worktreeIdComparisonKey } from './id' describe('WORKTREE_ID_SEPARATOR', () => { @@ -119,3 +120,65 @@ describe('getWorktreePathBasenameFromId', () => { expect(getWorktreePathBasenameFromId('repo-123::')).toBeNull() }) }) + +/** + * #16243: the renderer can only address a workspace by `id:::`, so the key must fold + * exactly the path spellings a `path:` selector already folds — and nothing more. + */ +describe('worktreeIdComparisonKey path-spelling parity for id: selectors (#16243)', () => { + const canonical = 'repo-123::/srv/workspaces/plugin' + const key = (worktreeId: string): string | null => worktreeIdComparisonKey(worktreeId) + + it('folds the path spellings a `path:` selector already accepts', () => { + expect(key('repo-123::/srv/workspaces/plugin/')).toBe(key(canonical)) + expect(key('repo-123::/srv//workspaces/plugin')).toBe(key(canonical)) + expect(key('repo-123::/srv/workspaces/Café'.normalize('NFD'))).toBe( + key('repo-123::/srv/workspaces/Café'.normalize('NFC')) + ) + }) + + it('folds no more loosely than `path:` does', () => { + // A leading `//` is a UNC root, not a doubled separator. + expect(key('repo-123://srv/workspaces/plugin')).not.toBe(key(canonical)) + // Dot segments are not canonicalized, so `id:` and `path:` still agree on refusing them. + expect(key('repo-123::/srv/./workspaces/plugin')).not.toBe(key(canonical)) + }) + + // #15598/#15616: the same Windows checkout is recorded with both separators. + it('folds Windows separator and drive-letter case, as `path:` already does', () => { + const windows = 'repo-123::D:/Agentic/game2' + expect(key('repo-123::D:\\Agentic\\game2')).toBe(key(windows)) + expect(key('repo-123::d:/agentic/game2')).toBe(key(windows)) + // A backslash is a valid POSIX filename character, so a POSIX path never folds it. + expect(key('repo-123::/srv\\workspaces')).not.toBe(key('repo-123::/srv/workspaces')) + // POSIX roots stay case-sensitive. + expect(key('repo-123::/srv/Workspaces')).not.toBe(key('repo-123::/srv/workspaces')) + }) + + it('keeps a UNC or WSL root distinct from a drive-letter location', () => { + // Different roots naming different filesystems must never collapse into one key. + expect(key('repo-123://server/share/game2')).not.toBe(key('repo-123::D:/server/share/game2')) + expect(key('repo-123://wsl.localhost/Ubuntu/home/dev/game2')).not.toBe( + key('repo-123::D:/home/dev/game2') + ) + // The two UNC aliases Windows exposes for one WSL distro are the same location. + expect(key('repo-123://wsl.localhost/Ubuntu/home/dev/game2')).toBe( + key('repo-123://wsl$/Ubuntu/home/dev/game2') + ) + }) + + it('never merges different repos, workspaces, or folder sessions', () => { + // STA-4343: the repo id stays exact, or a removal lands on a repo the caller never confirmed. + expect(key('repo-999::/srv/workspaces/plugin')).not.toBe(key(canonical)) + expect(key('repo-123::/srv/workspaces/other')).not.toBe(key(canonical)) + expect(key('repo-a::/srv/folder::workspace:123e4567-e89b-12d3-a456-426614174000')).not.toBe( + key('repo-a::/srv/folder::workspace:123e4567-e89b-12d3-a456-426614174001') + ) + }) + + it('returns null for ids with no repo boundary so callers keep exact matching', () => { + expect(key('repo-a')).toBeNull() + expect(key('repo-a::')).toBeNull() + expect(key('/srv/workspaces/plugin')).toBeNull() + }) +}) diff --git a/src/shared/worktree/id.ts b/src/shared/worktree/id.ts index d71cbac66eb..478ac6bfa76 100644 --- a/src/shared/worktree/id.ts +++ b/src/shared/worktree/id.ts @@ -1,3 +1,4 @@ +import { normalizeRuntimePathForComparison } from '../cross-platform-path' import { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' export { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' @@ -17,6 +18,22 @@ export function getRepoIdFromWorktreeId(worktreeId: string): string { return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx) } +/** + * Canonical comparison form of a worktree id: the repoId is compared EXACT and only the path folds, + * through the same `normalizeRuntimePathForComparison` a `path:` selector has always applied and + * byte-exact id matching denied the renderer (#16243). Null for a malformed id, so callers keep + * exact matching for it. Comparison only — never persist or return this key. + */ +export function worktreeIdComparisonKey(worktreeId: string): string | null { + const parsed = splitWorktreeId(worktreeId) + if (!parsed || !parsed.repoId || !parsed.worktreePath) { + return null + } + return `${parsed.repoId}${WORKTREE_ID_SEPARATOR}${normalizeRuntimePathForComparison( + parsed.worktreePath + )}` +} + export function splitWorktreeId(worktreeId: string): ParsedWorktreeId | null { const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR) if (separatorIdx === -1) { From 9062494f9b232112cbbe137f985a6ff67c8da37b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:37:16 -0700 Subject: [PATCH 9/9] fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript (#16587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript #15036 reported "1 transcript skipped / database is locked" with both Agent Session History scopes empty. Two separate defects. The panel counts every unkinded scan issue as a skipped transcript, so a failure that lost an entire *source* was reported as one lost *file*. The whole-database failure is now kinded `scope`, and an unknown `kind` from a newer host degrades to `scope` instead of failing validation and coming back unkinded — a mixed-version remote host previously turned a source-level failure into a phantom skipped transcript. The read also inherited sqlite3's 0 ms busy timeout, so a genuinely contended open failed in ~1 ms. It now opens once with a bounded timeout. No retry loop: sqlite's own busy handler already blocks and retries internally for the whole timeout, and WAL readers do not block on a writer at all (measured: 547/547 cross-process reads at timeout=0 while a writer held open transactions). Measured against a real Ubuntu-24.04 distro, Windows cannot take SQLite's file locks over \\wsl.localhost at all: an idle, never-WAL, nothing-attached database still answers SQLITE_BUSY, a 5 s busy timeout does not change it, and the identical bytes open fine once copied to local disk. So a lock-family error on that share never means "a writer holds it" and no timeout can help. The copy says so rather than sending the user after a write-ahead log that is not the problem. Restoring those sessions needs an in-distro read; that is a follow-up, and this PR no longer pretends a timeout will do it. immutable=1 is deliberately not used as a workaround: over the same share it opens and returns 100 of 150 rows, silently dropping everything still in the uncheckpointed -wal — in a history panel, exactly the newest sessions. * skip the provably futile busy wait on \\wsl.localhost paths --- .../session-list-result-validation.test.ts | 33 +++ .../session-list-result-validation.ts | 8 +- .../session-scanner-opencode-sqlite-list.ts | 43 ++-- ...ssion-scanner-opencode-sqlite-open.test.ts | 238 ++++++++++++++++++ .../session-scanner-opencode-sqlite-open.ts | 105 ++++++++ ...n-scanner-opencode-sqlite-worker-client.ts | 3 + .../session-scanner-opencode-sqlite.ts | 176 ++++++------- src/main/codex/codex-session-index-heal.ts | 3 +- src/main/sqlite/sqlite-read-failure.test.ts | 113 +++++++++ src/main/sqlite/sqlite-read-failure.ts | 77 ++++++ src/main/sqlite/sync-database.test.ts | 80 ++++++ .../ai-vault-scan-issue-state.test.ts | 18 ++ .../ai-vault-scan-issue-state.ts | 4 +- 13 files changed, 787 insertions(+), 114 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-sqlite-open.ts create mode 100644 src/main/sqlite/sqlite-read-failure.test.ts create mode 100644 src/main/sqlite/sqlite-read-failure.ts diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index af1a5e34ec2..484b8c4eb6d 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -112,6 +112,39 @@ describe('parseAiVaultListResult', () => { }) }) + // #15036: a `kind` this build has never heard of used to fail the whole issue, + // and the fallback re-reported it as an unkinded entry — i.e. a newer host's + // whole-source failure came back to an older client as a skipped transcript. + it('keeps an issue whose kind this build does not know, as its own kinded row', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession()], + issues: [ + { + agent: 'opencode', + kind: 'source-from-a-newer-host', + path: '/home/ada/.local/share/opencode/opencode.db', + message: 'OpenCode history was skipped.' + } + ], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([ + expect.objectContaining({ kind: 'scope', message: 'OpenCode history was skipped.' }) + ]) + expect(parsed.issues.some((issue) => issue.message.includes('invalid'))).toBe(false) + }) + + it('leaves an unkinded issue unkinded so real skipped transcripts still count', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession()], + issues: [{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues[0]?.kind).toBeUndefined() + }) + it('rejects a malformed result envelope', () => { expect(() => parseAiVaultListResult({ sessions: [] })).toThrow() }) diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index abcdd1612b3..06502cd4a0b 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -80,7 +80,13 @@ const aiVaultSessionSchema = z.object({ const aiVaultScanIssueSchema = z.object({ executionHostId: executionHostIdSchema.optional(), agent: z.string().min(1), - kind: z.enum(['host', 'scope', 'notice']).optional(), + // Why (#15036): a `kind` this build has never heard of used to fail the whole + // issue, which the fallback re-reported as an *unkinded* "invalid entry" — i.e. + // a newer host's source-level failure came back as a skipped transcript. An + // unknown kind degrades to 'scope' (its own muted row); absent stays absent. + kind: z + .union([z.enum(['host', 'scope', 'notice']), z.string().transform(() => 'scope' as const)]) + .optional(), path: z.string(), message: z.string() }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index 4b86d04161a..93f9daab98c 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -3,9 +3,12 @@ import { buildOpenCodeSqliteCandidatePath, splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { + openCodeDatabaseScanIssue, + readOpenCodeDatabase +} from './session-scanner-opencode-sqlite-open' import type { SessionFileCandidate } from './session-scanner-types' -import { errorMessage } from './session-scanner-values' -import SyncDatabase from '../sqlite/sync-database' +import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: the SQLite session-list query + reader lives in its own electron-free @@ -18,12 +21,6 @@ type SessionRow = { time_updated: number } -function openReadonlyDatabase(dbPath: string): SyncDatabase { - const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) - db.pragma('query_only = ON') - return db -} - function canReadOpenCodeSessions(db: SyncDatabase): boolean { return ( tableExists(db, 'session') && @@ -48,6 +45,15 @@ function buildSessionListQuery(db: SyncDatabase, limited: boolean): string { ${limited ? 'LIMIT ?' : ''}` } +function readSessionRows(db: SyncDatabase, limit: number): SessionRow[] { + if (!canReadOpenCodeSessions(db)) { + return [] + } + const limited = Number.isFinite(limit) + const statement = db.prepare(buildSessionListQuery(db, limited)) + return (limited ? statement.all(limit) : statement.all()) as SessionRow[] +} + function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { const mtimeMs = typeof row.time_updated === 'number' && row.time_updated > 0 @@ -99,26 +105,17 @@ export async function listOpenCodeSqliteSessions(args: { }): Promise { const candidates: SessionFileCandidate[] = [] for (const dbPath of args.dbPaths) { - let db: SyncDatabase | null = null try { - db = openReadonlyDatabase(dbPath) - if (!canReadOpenCodeSessions(db)) { - continue - } - const limited = Number.isFinite(args.limit) - const statement = db.prepare(buildSessionListQuery(db, limited)) - const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[] + const rows = readOpenCodeDatabase({ + dbPath, + read: (db) => readSessionRows(db, args.limit) + }) for (const row of rows) { candidates.push(rowToCandidate(row, dbPath)) } } catch (err) { - args.issues.push({ - agent: 'opencode', - path: dbPath, - message: errorMessage(err) - }) - } finally { - db?.close() + // A whole DB failed, not one transcript: kinded so the panel says so. + args.issues.push(openCodeDatabaseScanIssue(dbPath, err)) } } return dedupeAndSortSqliteCandidates(candidates) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts new file mode 100644 index 00000000000..7bfd72f9b51 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { afterEach, describe, expect, it } from 'vitest' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import Database from '../sqlite/sync-database' +import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' +import { + openCodeBusyTimeoutMs, + openCodeDatabaseScanIssue, + readOpenCodeDatabase +} from './session-scanner-opencode-sqlite-open' + +// Reproduces #15036: OpenCode holds opencode.db open while it runs, so a scan +// lands mid-write. The reader had no busy timeout, failed in ~1 ms with the bare +// driver string "database is locked", and one failed DB emptied both scopes. + +let tempDirs: string[] = [] +let lockHolders: Worker[] = [] + +afterEach(async () => { + await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) + lockHolders = [] + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +const SCHEMA = ` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + parent_id TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + ) +` + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-open-')) + tempDirs.push(dir) + return dir +} + +function seededDatabase(name: string, sessionId: string): string { + const path = join(tempDir(), name) + const db = new Database(path) + db.exec('PRAGMA journal_mode=DELETE') + db.exec(SCHEMA) + db.prepare('INSERT INTO session (id, time_created, time_updated) VALUES (?, ?, ?)').run( + sessionId, + 1_700_000_000_000, + 1_700_000_001_000 + ) + db.close() + return path +} + +// The lock holder must live on another thread: sqlite3_busy_timeout sleeps +// synchronously, so a same-thread timer could never fire to release it. +const LOCK_HOLDER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const { DatabaseSync } = require('node:sqlite') + const db = new DatabaseSync(workerData.path) + db.exec('BEGIN EXCLUSIVE') + db.exec("INSERT INTO session (id, time_created, time_updated) VALUES ('locked-write', 1, 1)") + parentPort.postMessage('locked') + setTimeout(() => { + db.exec('ROLLBACK') + db.close() + parentPort.postMessage('released') + }, workerData.holdMs) +` + +async function holdWriteLock(path: string, holdMs: number): Promise { + const worker = new Worker(LOCK_HOLDER_SOURCE, { eval: true, workerData: { path, holdMs } }) + lockHolders.push(worker) + await new Promise((resolve, reject) => { + worker.once('message', () => resolve()) + worker.once('error', reject) + }) +} + +describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', () => { + it('reports the failure as a whole source, not as a skipped transcript', async () => { + const path = seededDatabase('opencode.db', 'session-a') + await holdWriteLock(path, 60_000) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) + + expect(candidates).toEqual([]) + expect(issues).toHaveLength(1) + // The panel counts only unkinded issues as skipped transcripts. + expect(issues[0]?.kind).toBe('scope') + expect(issues[0]?.message).toContain('OpenCode is writing to opencode.db') + expect(issues[0]?.message).not.toBe('database is locked') + }) + + it('still returns sessions from every other database', async () => { + const contended = seededDatabase('opencode.db', 'session-a') + const healthy = seededDatabase('opencode-alt.db', 'session-b') + await holdWriteLock(contended, 60_000) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [contended, healthy], + limit: 10, + issues + }) + + expect(candidates).toHaveLength(1) + expect(candidates[0]?.file.path).toContain('session-b') + expect(issues).toHaveLength(1) + }) + + it('reads the sessions once the write finishes inside the busy timeout', async () => { + const path = seededDatabase('opencode.db', 'session-a') + // Long enough that only a real busy timeout — not a lucky fast open — survives it. + await holdWriteLock(path, 900) + const issues: AiVaultScanIssue[] = [] + + const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) + + expect(issues).toEqual([]) + expect(candidates).toHaveLength(1) + expect(candidates[0]?.file.path).toContain('session-a') + }) +}) + +describe('readOpenCodeDatabase', () => { + it('closes the handle on the success path', () => { + const path = seededDatabase('opencode.db', 'session-a') + let captured: Database.Database | null = null + + const rows = readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + captured = db + return db.prepare('SELECT id FROM session').all() + } + }) + + expect(rows).toEqual([{ id: 'session-a' }]) + expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) + }) + + it('closes the handle when the read throws', () => { + const path = seededDatabase('opencode.db', 'session-a') + let captured: Database.Database | null = null + + expect(() => + readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + captured = db + throw new Error('read blew up') + } + }) + ).toThrow('read blew up') + expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) + }) +}) + +describe('openCodeBusyTimeoutMs', () => { + // Measured on a real Windows host against a real distro: a read over + // \\wsl.localhost fails whatever the timeout, and the wait is not bounded by + // it -- 1500 took ~2400 ms, 5000 took ~7250 ms, 0 failed in ~21 ms. Waiting + // there is dead time on every scan, and enough such databases would spend the + // list worker's whole deadline. + it('does not wait on a share where the wait provably cannot succeed', () => { + expect(openCodeBusyTimeoutMs('\\\\wsl.localhost\\Ubuntu\\home\\ada\\opencode.db')).toBe(0) + expect(openCodeBusyTimeoutMs('\\\\wsl$\\Ubuntu\\home\\ada\\opencode.db')).toBe(0) + }) + + it('still waits on a path where a writer really can be holding the lock', () => { + expect(openCodeBusyTimeoutMs('C:\\Users\\ada\\opencode.db')).toBe(1_500) + expect(openCodeBusyTimeoutMs('/home/ada/.local/share/opencode/opencode.db')).toBe(1_500) + }) +}) + +describe('openCodeDatabaseScanIssue', () => { + const cantOpen = Object.assign(new Error('unable to open database file'), { errcode: 14 }) + + it('names the wal-index over a WSL share rather than repeating the driver string', () => { + const issue = openCodeDatabaseScanIssue( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + cantOpen + ) + + expect(issue.kind).toBe('scope') + expect(issue.message).toContain('\\\\wsl.localhost') + // Checkpointing cannot fix a share that refuses SQLite's locks, so the copy + // must not send the user after the write-ahead log. + expect(issue.message).not.toContain('write-ahead log') + }) + + it('keeps the advice generic for a non-WSL path', () => { + const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', cantOpen) + + expect(issue.message).not.toContain('wsl.localhost') + expect(issue.message).toContain('write-ahead log') + }) + + // Measured against a real Ubuntu-24.04 distro: Windows cannot take SQLite's + // locks over \\wsl.localhost at all. An idle, never-WAL database answers + // SQLITE_BUSY the same way, while the identical bytes read fine once copied + // to local disk — so no busy timeout and no journal mode changes the outcome. + it('does not blame a live writer for a lock-family error over a WSL share', () => { + const busy = Object.assign(new Error('database is locked'), { errcode: 5 }) + const issue = openCodeDatabaseScanIssue( + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + busy + ) + + expect(issue.message).not.toContain('is writing to') + expect(issue.message).toContain('inside the distro') + }) + + it('still blames a live writer for the same error on a local path', () => { + const busy = Object.assign(new Error('database is locked'), { errcode: 5 }) + const issue = openCodeDatabaseScanIssue('/home/ada/.local/share/opencode/opencode.db', busy) + + expect(issue.message).toContain('is writing to') + }) + + it('reports anything else with its underlying cause', () => { + const issue = openCodeDatabaseScanIssue( + '/home/ada/.local/share/opencode/opencode.db', + Object.assign(new Error('database disk image is malformed'), { errcode: 11 }) + ) + + expect(issue.kind).toBe('scope') + expect(issue.message).not.toContain('write-ahead log') + expect(issue.message).toContain('database disk image is malformed') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts new file mode 100644 index 00000000000..84c2cca73d4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -0,0 +1,105 @@ +import { basename } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { isWslUncPath } from '../../shared/wsl-paths' +import SyncDatabase from '../sqlite/sync-database' +import { classifySqliteReadFailure } from '../sqlite/sqlite-read-failure' +import { errorMessage } from './session-scanner-values' + +// Why (#15036): the read used to inherit sqlite3's 0 ms busy timeout, so a +// genuinely contended open failed in ~1 ms. WAL readers almost never block on a +// writer, so this is insurance rather than the fix -- it stays a single bounded +// open, with no retry loop on top of sqlite's own busy handler. +const OPENCODE_SQLITE_BUSY_TIMEOUT_MS = 1_500 + +/** + * Open one OpenCode database for reading. + * + * Read-only, plus `query_only` as a belt-and-suspenders guard so a bug in a + * SELECT list can never mutate the user's opencode.db. + * @param dbPath - Absolute path to an opencode.db file. + * @returns An open handle the caller must close. + */ +/** + * Busy timeout for one database. + * + * Zero over the WSL share because waiting there is provably useless: Windows + * cannot take SQLite's locks over \\wsl.localhost at all, so the open fails + * whatever the timeout. Measured on a real host, the wait is not even bounded + * by the value — timeout 1500 took ~2400 ms and timeout 5000 took ~7250 ms, + * while 0 failed in ~21 ms. Paying that per database, per scan, buys nothing + * and enough such databases would spend the list worker's whole 30 s deadline. + * @param dbPath - Absolute path to an opencode.db file. + * @returns Milliseconds to let SQLite's busy handler wait. + */ +export function openCodeBusyTimeoutMs(dbPath: string): number { + return isWslUncPath(dbPath) ? 0 : OPENCODE_SQLITE_BUSY_TIMEOUT_MS +} + +function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { + readonly: true, + fileMustExist: true, + timeout: openCodeBusyTimeoutMs(dbPath) + }) + db.pragma('query_only = ON') + return db +} + +/** + * Run one synchronous read against an OpenCode database. + * @param args.dbPath - Absolute path to an opencode.db file. + * @param args.read - Synchronous reader; its result must be fully materialized. + * @returns The reader's value; rethrows whatever SQLite raised. + */ +export function readOpenCodeDatabase(args: { + dbPath: string + read: (db: SyncDatabase) => T +}): T { + const db = openOpenCodeDatabaseReadonly(args.dbPath) + try { + return args.read(db) + } finally { + db.close() + } +} + +/** + * Describe a whole-database read failure as a scan issue. + * + * Kinded so the panel renders this copy instead of counting a failed *source* + * as a skipped *transcript*; `scope` rather than a new member — see + * `aiVaultScanIssueSchema` in session-list-result-validation.ts. + * @param dbPath - Absolute path to the opencode.db that could not be read. + * @param error - The thrown value from the read. + * @returns A kinded scan issue with actionable copy. + */ +export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVaultScanIssue { + const name = basename(dbPath) + // `fileMustExist` already proved the database file is there before SQLite ran, + // so this needs no fs probe of its own — an extra sync stat on a 9p share is + // exactly the hang the WSL transcript gate exists to prevent. + const kind = classifySqliteReadFailure({ error, databaseFileExists: true }) + // Why: measured against a real distro. Windows cannot take SQLite's file locks + // over \\wsl.localhost at all — an idle, never-WAL, nothing-attached database + // answers SQLITE_BUSY just the same, and a 5 s busy timeout does not change it, + // while the identical bytes copied to local disk open fine. So on this share a + // lock-family error never means "a writer holds it", and no timeout can help. + const overWslShare = isWslUncPath(dbPath) + const detail = + kind === 'contended' && !overWslShare + ? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.` + : kind === 'unreadable' + ? `OpenCode history in ${name} could not be read: ${errorMessage(error)}` + : `OpenCode history in ${name} could not be read. ${unreadableShareAdvice(dbPath)}` + return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail } +} + +function unreadableShareAdvice(dbPath: string): string { + // Named only when the evidence supports it; a generic share gets generic copy. + // Deliberately not "flush the write-ahead log": checkpointing changes nothing + // here, and telling the user to try it would send them after a fix that cannot + // work. The share itself is the blocker. + return isWslUncPath(dbPath) + ? 'Windows cannot open SQLite databases over the \\\\wsl.localhost share, so this history has to be read from inside the distro.' + : 'Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.' +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index ef156b0b3f4..e8fc78907ba 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -94,8 +94,10 @@ export class OpenCodeSqliteWorkerClient { return value.candidates } catch (err) { if (err instanceof OpenCodeSqliteWorkerUnavailableError) { + // Kinded: a whole source failed, not a transcript. args.issues.push({ agent: 'opencode', + kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: 'OpenCode history was skipped because its background scanner could not start; the app remains responsive.' @@ -106,6 +108,7 @@ export class OpenCodeSqliteWorkerClient { // scan, surfaced as one scan issue rather than an unbounded stall. args.issues.push({ agent: 'opencode', + kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: `OpenCode history scan did not complete: ${errorMessage(err)}` }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index c0ffdbfb894..322a38f214f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -9,8 +9,9 @@ import { normalizeFullFirstUserPromptText, shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' +import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' import { normalizeTitleText } from './session-scanner-values' -import SyncDatabase from '../sqlite/sync-database' +import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files @@ -53,14 +54,6 @@ type PreviewRow = { summary_body: string | null } -function openReadonlyDatabase(dbPath: string): SyncDatabase { - const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) - // Why: belt-and-suspenders guard so a bug in the SELECT list can never - // mutate the user's opencode.db. - db.pragma('query_only = ON') - return db -} - function canReadOpenCodeSessions(db: SyncDatabase): boolean { return ( tableExists(db, 'session') && @@ -256,90 +249,97 @@ export async function parseOpenCodeSqliteSession(args: { sessionId: string platform: NodeJS.Platform }): Promise { - const { dbPath, sessionId, platform } = args - let db: SyncDatabase | null = null - try { - db = openReadonlyDatabase(dbPath) - if (!canReadOpenCodeSessions(db)) { - return null - } - const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined - if (!row || row.id !== sessionId) { - return null - } + return readOpenCodeDatabase({ + dbPath: args.dbPath, + read: (db) => readSession({ db, ...args }) + }) +} + +// Extracted so the open wrapper owns the handle's lifetime. +function readSession(args: { + db: SyncDatabase + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): AiVaultSession | null { + const { db, dbPath, sessionId, platform } = args + if (!canReadOpenCodeSessions(db)) { + return null + } + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } - const mtimeMs = - typeof row.time_updated === 'number' && row.time_updated > 0 - ? row.time_updated - : row.time_created - // Why: discovery uses a synthetic db#session path only for parser routing. - // The UI's log open/reveal actions need a real filesystem path. - const accumulator = createAccumulator({ - agent: 'opencode', - file: { - path: dbPath, - mtimeMs, - modifiedAt: new Date(mtimeMs).toISOString() - }, - sessionId - }) - accumulator.title = normalizeTitleText(row.title ?? '') - accumulator.cwd = row.directory - accumulator.model = extractModelId(row.model_json) - accumulator.totalTokens = - (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) - accumulator.messageCount = row.message_count ?? 0 - updateTimeline(accumulator, row.time_created) - updateTimeline(accumulator, row.time_updated) + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + // Why: discovery uses a synthetic db#session path only for parser routing. + // The UI's log open/reveal actions need a real filesystem path. + const accumulator = createAccumulator({ + agent: 'opencode', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model_json) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) - const previewSql = buildPreviewQuery(db) - if (previewSql) { - // Why: SQL already dropped anything older than the newest-N window, so the - // accumulator never shifts and cannot detect the truncation itself. Ask for - // one extra row so an exactly-full window is not mistaken for a trimmed one. - const probedRows = db - .prepare(previewSql) - .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] - if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { - accumulator.previewMessagesTruncated = true + const previewSql = buildPreviewQuery(db) + if (previewSql) { + // Why: SQL already dropped anything older than the newest-N window, so the + // accumulator never shifts and cannot detect the truncation itself. Ask for + // one extra row so an exactly-full window is not mistaken for a trimmed one. + const probedRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] + if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { + accumulator.previewMessagesTruncated = true + } + // Newest-first, so the probe row to drop is the oldest one at the tail. + const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) + // Why: query returns newest-first; push in chronological order so the + // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT + // messages. + for (let i = previewRows.length - 1; i >= 0; i--) { + const previewRow = previewRows[i] + if (!previewRow) { + continue } - // Newest-first, so the probe row to drop is the oldest one at the tail. - const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) - // Why: query returns newest-first; push in chronological order so the - // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT - // messages. - for (let i = previewRows.length - 1; i >= 0; i--) { - const previewRow = previewRows[i] - if (!previewRow) { - continue - } - const text = extractPartText(previewRow.part_data) - if (!text) { - continue - } - addPreviewMessage(accumulator, { - role: mapPreviewRole(previewRow.role), - text, - timestamp: previewRow.time_created, - // Preview window is newest-N; first-prompt is loaded separately below. - seedFirstUserPrompt: false - }) - if (previewRow.role === 'user' && !accumulator.title) { - accumulator.title = - normalizeTitleText(previewRow.summary_title ?? '') || - normalizeTitleText(previewRow.summary_body ?? '') - } + const text = extractPartText(previewRow.part_data) + if (!text) { + continue } + addPreviewMessage(accumulator, { + role: mapPreviewRole(previewRow.role), + text, + timestamp: previewRow.time_created, + // Preview window is newest-N; first-prompt is loaded separately below. + seedFirstUserPrompt: false + }) + if (previewRow.role === 'user' && !accumulator.title) { + accumulator.title = + normalizeTitleText(previewRow.summary_title ?? '') || + normalizeTitleText(previewRow.summary_body ?? '') + } } + } - // Why: list preview only joins the newest messages. On-demand copy needs the - // session's earliest real user text part, not a later turn still in the window. - if (shouldCaptureFullFirstUserPrompt()) { - accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) - } + // Why: list preview only joins the newest messages. On-demand copy needs the + // session's earliest real user text part, not a later turn still in the window. + if (shouldCaptureFullFirstUserPrompt()) { + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + } - return finalizeSession(accumulator, platform) - } finally { - db?.close() - } + return finalizeSession(accumulator, platform) } diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 645d96a76b4..12747312646 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -1,5 +1,6 @@ import { dirname, join } from 'node:path' import { resolveCodexCommand } from '../codex-cli/command' +import { isTransientSqliteContention } from '../sqlite/sqlite-read-failure' import { getSpawnArgsForWindows } from '../win32-utils' import { getCodexSessionBackfillStateDirPath } from './codex-home-paths' import { resolveCodexSessionBackfillPaths } from './codex-session-backfill' @@ -231,7 +232,7 @@ async function healOneThread( recordHealOutcome(paths, thread, 'missing') return } - if (/SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i.test(message)) { + if (isTransientSqliteContention(message)) { // Why: an active Codex process can briefly own sqlite; leave the id off // the ledger and abort this pass so a later startup resumes it. throw error diff --git a/src/main/sqlite/sqlite-read-failure.test.ts b/src/main/sqlite/sqlite-read-failure.test.ts new file mode 100644 index 00000000000..8bd43d54eaa --- /dev/null +++ b/src/main/sqlite/sqlite-read-failure.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase from './sync-database' +import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite-read-failure' + +// Error codes here are the ones a real node:sqlite open produces: a contended +// database reports errcode 5 ("database is locked"), while a read-only WAL open +// with no usable -shm reports errcode 14 ("unable to open database file"). The +// two need opposite responses, so the classifier must never conflate them. + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function contendedDatabase(): { path: string; release: () => void } { + const dir = mkdtempSync(join(tmpdir(), 'orca-sqlite-failure-')) + tempDirs.push(dir) + const path = join(dir, 'contended.db') + const writer = new SyncDatabase(path) + writer.exec('PRAGMA journal_mode=DELETE') + writer.exec('CREATE TABLE session (id TEXT PRIMARY KEY)') + writer.exec('BEGIN EXCLUSIVE') + writer.exec("INSERT INTO session VALUES ('a')") + return { + path, + release: () => { + writer.exec('COMMIT') + writer.close() + } + } +} + +describe('isTransientSqliteContention', () => { + it('recognizes a real SQLITE_BUSY thrown by a read-only open', () => { + const contended = contendedDatabase() + let thrown: unknown + try { + new SyncDatabase(contended.path, { readonly: true, timeout: 0 }) + .prepare('SELECT id FROM session') + .all() + } catch (error) { + thrown = error + } finally { + contended.release() + } + + expect((thrown as { errcode?: number }).errcode).toBe(5) + expect(isTransientSqliteContention(thrown)).toBe(true) + }) + + it('recognizes a relayed message with no errcode, as the Codex heal pass sees it', () => { + expect( + isTransientSqliteContention('codex app-server thread/read failed: database is locked') + ).toBe(true) + expect(isTransientSqliteContention(new Error('SQLITE_LOCKED: table is locked'))).toBe(true) + }) + + it('reads extended result codes through their primary code', () => { + // SQLITE_BUSY_SNAPSHOT (517) and SQLITE_BUSY_RECOVERY (261) both pack 5. + expect(isTransientSqliteContention({ errcode: 517, message: 'busy snapshot' })).toBe(true) + expect(isTransientSqliteContention({ errcode: 261, message: 'recovery' })).toBe(true) + }) + + it('does not treat an unreadable or absent database as contention', () => { + expect(isTransientSqliteContention(new Error('file is not a database'))).toBe(false) + expect( + isTransientSqliteContention({ errcode: 14, message: 'unable to open database file' }) + ).toBe(false) + }) +}) + +describe('classifySqliteReadFailure', () => { + it('classifies contention as retryable regardless of the file evidence', () => { + const error = { errcode: 5, message: 'database is locked' } + + expect(classifySqliteReadFailure({ error, databaseFileExists: true })).toBe('contended') + expect(classifySqliteReadFailure({ error, databaseFileExists: false })).toBe('contended') + }) + + it('classifies SQLITE_CANTOPEN against a present database as an unreachable wal-index', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 14, message: 'unable to open database file' }, + databaseFileExists: true + }) + ).toBe('wal-index-unavailable') + }) + + it('does not blame the wal-index when the database file itself is gone', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 14, message: 'unable to open database file' }, + databaseFileExists: false + }) + ).toBe('unreadable') + }) + + it('falls back to unreadable for anything else', () => { + expect( + classifySqliteReadFailure({ + error: { errcode: 11, message: 'database disk image is malformed' }, + databaseFileExists: true + }) + ).toBe('unreadable') + }) +}) diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts new file mode 100644 index 00000000000..f96176eee0a --- /dev/null +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -0,0 +1,77 @@ +// Why (#15036): a read-only SQLite open can fail for reasons that demand +// opposite responses, and both arrive as an opaque driver string. Contention is +// transient — retry later, never persist it as a permanent failure. A missing +// wal-index is structural — retrying forever cannot fix it. + +/** Primary result codes; extended codes pack the primary code in the low byte. */ +const SQLITE_BUSY = 5 +const SQLITE_LOCKED = 6 +const SQLITE_CANTOPEN = 14 + +// Shared with the Codex index-heal pass, which only ever sees a relayed message +// string (app-server RPC drops `errcode`), so message matching is not optional. +const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i + +function primaryErrcode(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('errcode' in error)) { + return null + } + const errcode = (error as { errcode?: unknown }).errcode + return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * True when a SQLite failure means "someone else holds the database right now". + * @param error - The thrown value from a `node:sqlite` call or a relayed message. + * @returns Whether the caller should retry later rather than record a failure. + */ +export function isTransientSqliteContention(error: unknown): boolean { + const errcode = primaryErrcode(error) + if (errcode === SQLITE_BUSY || errcode === SQLITE_LOCKED) { + return true + } + return CONTENTION_MESSAGE.test(errorText(error)) +} + +export type SqliteReadFailureKind = + /** Another connection holds the lock; the same read can succeed later. */ + | 'contended' + /** + * The database file itself is present, but SQLite still could not open the + * set of files it needs — in practice the `-wal`/`-shm` pair a read-only WAL + * open requires, which shares that cannot host SQLite shared memory (9p + * `\\wsl.localhost`, SMB, SSHFS) refuse. Retrying cannot fix it. + */ + | 'wal-index-unavailable' + /** Anything else: corrupt file, wrong schema, permissions. */ + | 'unreadable' + +/** + * Classify why a read-only SQLite open or query failed. + * + * Pure: the caller supplies the "does the database file exist" evidence, so + * neither branch needs a filesystem probe of its own. That matters on a WSL 9p + * share, where an extra sync stat is exactly the hang the transcript gate exists + * to prevent. + * @param args.error - The thrown value. + * @param args.databaseFileExists - Whether the main database file was present. + * @returns Which of the three failure modes the error represents. + */ +export function classifySqliteReadFailure(args: { + error: unknown + databaseFileExists: boolean +}): SqliteReadFailureKind { + if (isTransientSqliteContention(args.error)) { + return 'contended' + } + // SQLITE_CANTOPEN against a database that is right there names a companion + // file — the wal-index a read-only WAL open needs — not the database itself. + if (primaryErrcode(args.error) === SQLITE_CANTOPEN && args.databaseFileExists) { + return 'wal-index-unavailable' + } + return 'unreadable' +} diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts index 34677095b26..fe3ba7e388d 100644 --- a/src/main/sqlite/sync-database.test.ts +++ b/src/main/sqlite/sync-database.test.ts @@ -1,11 +1,13 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Worker } from 'node:worker_threads' import { afterEach, describe, expect, it } from 'vitest' import SyncDatabase from './sync-database' const temporaryDirectories: string[] = [] const openDatabases: SyncDatabase.Database[] = [] +const lockHolders: Worker[] = [] async function createDatabase(): Promise { const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-')) @@ -20,6 +22,7 @@ async function createDatabase(): Promise { } afterEach(async () => { + await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) for (const db of openDatabases.splice(0)) { try { db.close() @@ -151,3 +154,80 @@ describe('SyncDatabase statement cache', () => { ) }) }) + +// Why (#15036): the OpenCode readers opened read-only with no busy timeout, so a +// contended DB failed in ~1 ms and emptied the whole panel. These pin that the +// wrapper really forwards `timeout` into sqlite3_busy_timeout. +describe('SyncDatabase read-only opens under contention', () => { + // The lock holder must live on another thread: sqlite3_busy_timeout sleeps + // synchronously, so a same-thread timer could never fire to release it. + const WRITER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const { DatabaseSync } = require('node:sqlite') + const db = new DatabaseSync(workerData.path) + db.exec('PRAGMA journal_mode=DELETE') + db.exec("CREATE TABLE items (id TEXT PRIMARY KEY); INSERT INTO items VALUES ('a')") + db.exec('BEGIN EXCLUSIVE') + db.exec("INSERT INTO items VALUES ('b')") + parentPort.postMessage('locked') + setTimeout(() => { + db.exec('COMMIT') + db.close() + parentPort.postMessage('released') + }, workerData.holdMs) + ` + + async function contendedDatabase(holdMs: number): Promise<{ path: string }> { + const directory = await mkdtemp(join(tmpdir(), 'orca-sync-database-busy-')) + temporaryDirectories.push(directory) + const path = join(directory, 'contended.db') + const worker = new Worker(WRITER_SOURCE, { eval: true, workerData: { path, holdMs } }) + lockHolders.push(worker) + await new Promise((resolve, reject) => { + worker.once('message', () => resolve()) + worker.once('error', reject) + }) + return { path } + } + + it('fails immediately with SQLITE_BUSY when no timeout is given', async () => { + const contended = await contendedDatabase(10_000) + const startedAt = Date.now() + + let thrown: unknown + try { + new SyncDatabase(contended.path, { readonly: true }).prepare('SELECT id FROM items').all() + } catch (error) { + thrown = error + } + + expect((thrown as { errcode?: number }).errcode).toBe(5) + expect((thrown as Error).message).toContain('database is locked') + expect(Date.now() - startedAt).toBeLessThan(200) + }) + + it('waits for the configured busy timeout before giving up', async () => { + const contended = await contendedDatabase(10_000) + const startedAt = Date.now() + + expect(() => + new SyncDatabase(contended.path, { readonly: true, timeout: 400 }) + .prepare('SELECT id FROM items') + .all() + ).toThrow(/database is locked/) + + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(350) + }) + + it('succeeds once the writer commits inside the busy timeout', async () => { + const contended = await contendedDatabase(150) + + const reader = new SyncDatabase(contended.path, { readonly: true, timeout: 5_000 }) + openDatabases.push(reader) + + expect(reader.prepare('SELECT id FROM items ORDER BY id').all()).toEqual([ + { id: 'a' }, + { id: 'b' } + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts index f4e4755eaf7..80ab908cd1a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts @@ -78,6 +78,24 @@ describe('aiVaultScanNoticeIssues', () => { expect(skippedAiVaultTranscriptCount(partial)).toBe(1) }) + // #15036: a locked opencode.db holds every OpenCode session, so reporting it + // as "1 transcript skipped" both understates the loss and buries the cause in + // a bare driver string. + it('gives an unreadable OpenCode database its own row instead of a skipped count', () => { + const sourceIssue = { + agent: 'opencode' as const, + kind: 'scope' as const, + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db', + message: + 'OpenCode is writing to opencode.db right now, so its history was skipped. It is read again on the next refresh.' + } + const empty = result([], [sourceIssue]) + + expect(skippedAiVaultTranscriptCount(empty)).toBe(0) + expect(skippedAiVaultTranscriptReasons(empty)).toEqual([]) + expect(aiVaultScanNoticeIssues(empty)).toEqual([sourceIssue]) + }) + it('does not repeat the blocking issue as a notice row', () => { const hostIssue = { executionHostId: 'ssh:dev-box' as const, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts index d73046f5580..5bf60d3a17a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.ts @@ -12,7 +12,9 @@ export function blockingAiVaultScanIssue( // Host and scope issues carry their own scanner-authored copy, so they get their // own rows instead of being counted as skipped transcripts — a partial scan // (one SSH host down, rest fine) must not report a connectivity failure as a -// skipped transcript file. +// skipped transcript file, and an unreadable *source* (a whole locked +// opencode.db holding every OpenCode session) must not read as "1 transcript +// skipped". export function aiVaultScanNoticeIssues(result: AiVaultListResult | null): AiVaultScanIssue[] { if (!result) { return []