From 59dc14ec2fc26ddabcc7f95e85cc5fb013014a39 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:02:51 -0700 Subject: [PATCH] Fix PTY spawn lifecycle: handle concurrent races and cleanup abandoned a Properly release Agent Teams leader handles when spawns are abandoned or fail, restore provisional PTY sizes on reattachment, and settle concurrent spawn races for the same pane. Add validation guards for destroyed renderers and improve handler re-registration to reset delivery state before bridging a new window. --- .../ipc/pty-buffer-snapshot-dispatch.test.ts | 26 ++-- .../pty-daemon-controller-teardown.test.ts | 56 ++++++++ .../ipc/pty-ipc-hidden-delivery-gate.test.ts | 2 +- .../ipc/pty-ipc-producer-flow-control.test.ts | 18 +++ .../ipc/pty-pane-materialization-race.test.ts | 118 +++++++++++++++++ .../pty-serializer-settlement-mapping.test.ts | 15 +++ ...pty-session-liveness-and-ownership.test.ts | 15 +++ .../pty-spawn-runtime-handle-binding.test.ts | 123 ++++++++++++++++++ src/main/ipc/pty/delivery/debug-snapshot.ts | 2 +- src/main/ipc/pty/delivery/payload.ts | 47 +++---- src/main/ipc/pty/delivery/producer-sync.ts | 13 +- src/main/ipc/pty/delivery/ssh-intake.ts | 4 +- src/main/ipc/pty/ipc/inspect.ts | 15 ++- src/main/ipc/pty/ipc/resize-visibility.ts | 4 + src/main/ipc/pty/ipc/serialize-buffer.ts | 9 +- src/main/ipc/pty/ipc/spawn-commit.ts | 1 + src/main/ipc/pty/ipc/spawn-env.ts | 1 + src/main/ipc/pty/ipc/spawn-options.ts | 18 ++- src/main/ipc/pty/ipc/spawn-run.ts | 38 +++++- src/main/ipc/pty/ipc/spawn-state.ts | 3 + src/main/ipc/pty/ipc/write-input.ts | 4 - src/main/ipc/pty/ipc/write.ts | 6 +- src/main/ipc/pty/pane/agent-session-owners.ts | 8 +- src/main/ipc/pty/pane/key-state.ts | 7 + src/main/ipc/pty/pane/serializer-state.ts | 6 + src/main/ipc/pty/provider/liveness.ts | 12 +- src/main/ipc/pty/provider/registry.ts | 7 +- src/main/ipc/pty/register-handlers.ts | 4 + src/main/ipc/pty/runtime/spawn-commit.ts | 7 + src/main/ipc/pty/runtime/spawn-early.ts | 30 +++-- src/main/ipc/pty/runtime/spawn-options.ts | 15 ++- src/main/ipc/pty/runtime/spawn.ts | 42 ++++-- src/main/ipc/pty/session.ts | 2 +- src/main/runtime/orca-runtime.ts | 6 + 34 files changed, 602 insertions(+), 82 deletions(-) diff --git a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts index 04ca18f997f..e08e6aa2090 100644 --- a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts +++ b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts @@ -48,8 +48,13 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => ) describe('registerPtyHandlers', () => { - const { handlers, mainWindow, createMockProc, installObservableDaemonTestProvider } = - setupPtyIpcSuite() + const { + handlers, + mainWindow, + mainWindowIpcEvent, + createMockProc, + installObservableDaemonTestProvider + } = setupPtyIpcSuite() describe('agent_started telemetry', () => { // Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event. @@ -190,7 +195,7 @@ describe('registerPtyHandlers', () => { expect(responseChannelRegistrations.length).toBe(1) // Drain the in-flight requests so the test doesn't leak timers. for (const requestId of getSentRequestIds()) { - listener(null, { requestId, snapshot: null }) + listener(mainWindowIpcEvent, { requestId, snapshot: null }) } await Promise.all(inflight) }) @@ -202,11 +207,11 @@ describe('registerPtyHandlers', () => { const requestIdA = ids[0] const requestIdB = ids[1] - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdB, snapshot: { data: 'B-data', cols: 80, rows: 24 } }) - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdA, snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' } }) @@ -224,11 +229,11 @@ describe('registerPtyHandlers', () => { const pending = controller.serializeBuffer('pty-1') const realRequestId = getSentRequestIds()[0] - listener(null, { + listener(mainWindowIpcEvent, { requestId: 'not-a-real-id', snapshot: { data: 'irrelevant', cols: 1, rows: 1 } }) - listener(null, { requestId: undefined, snapshot: null }) + listener(mainWindowIpcEvent, { requestId: undefined, snapshot: null }) let resolved = false void pending.then(() => { @@ -237,7 +242,10 @@ describe('registerPtyHandlers', () => { await new Promise((r) => setTimeout(r, 0)) expect(resolved).toBe(false) - listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } }) + listener(mainWindowIpcEvent, { + requestId: realRequestId, + snapshot: { data: 'ok', cols: 80, rows: 24 } + }) await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 }) }) it('resolves to null and removes the entry when the 750ms timeout fires', async () => { @@ -255,7 +263,7 @@ describe('registerPtyHandlers', () => { const { listener, controller } = setup() const pending = controller.serializeBuffer('pty-bad') const requestId = getSentRequestIds()[0] - listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) + listener(mainWindowIpcEvent, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) await expect(pending).resolves.toBeNull() }) }) diff --git a/src/main/ipc/pty-daemon-controller-teardown.test.ts b/src/main/ipc/pty-daemon-controller-teardown.test.ts index 73ae897b8b3..63786b361fb 100644 --- a/src/main/ipc/pty-daemon-controller-teardown.test.ts +++ b/src/main/ipc/pty-daemon-controller-teardown.test.ts @@ -153,6 +153,62 @@ describe('registerPtyHandlers', () => { }) expect(runtime.onPtyExit).not.toHaveBeenCalled() }) + it('bounds keep-history inventory polls by the settlement deadline', async () => { + vi.useFakeTimers() + try { + const listProcesses = vi.fn(async (_opts?: { deadlineMs?: number }) => []) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses, + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: ( + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } + ) => Promise + } + const callerDeadlineMs = Date.now() + 5_000 + const stopPromise = controller.stopAndWait('local-pty', { + keepHistory: true, + deadlineMs: callerDeadlineMs + }) + + await vi.advanceTimersByTimeAsync(1_000) + await expect(stopPromise).resolves.toBe(true) + + expect(listProcesses.mock.calls[0]?.[0]).toEqual({ deadlineMs: callerDeadlineMs }) + const settlementDeadlines = listProcesses.mock.calls + .slice(1) + .map(([opts]) => opts?.deadlineMs) + expect(settlementDeadlines.length).toBeGreaterThan(0) + expect(new Set(settlementDeadlines)).toEqual(new Set([callerDeadlineMs - 4_000])) + } finally { + vi.useRealTimers() + } + }) it('uses inventory, not an incarnation-less exit, to confirm the current PTY stopped', async () => { const exitListeners = new Set< (payload: { id: string; code: number; incarnationId?: string }) => void diff --git a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts index f323039191f..bfacfc96935 100644 --- a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts +++ b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts @@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => { }) expect(warnSpy).toHaveBeenCalledWith( '[pty] hidden-delivery gate is dropping bytes for a visible/active pty', - expect.objectContaining({ id: result.id, visible: true }) + expect.objectContaining({ id: redactPtyIdForDiagnostics(result.id), visible: true }) ) // Unhiding resolves the contradiction. diff --git a/src/main/ipc/pty-ipc-producer-flow-control.test.ts b/src/main/ipc/pty-ipc-producer-flow-control.test.ts index bfc65403daa..8b97751dba7 100644 --- a/src/main/ipc/pty-ipc-producer-flow-control.test.ts +++ b/src/main/ipc/pty-ipc-producer-flow-control.test.ts @@ -206,6 +206,24 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() } }) + it('releases a paused producer when handlers re-register for a replacement window', () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + + // Re-registration hands delivery to a new window; the outgoing session must run its real + // lifecycle reset before the bridge is neutralized or this shell stays paused forever. + registerPtyHandlers(mainWindow as never) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + } finally { + vi.useRealTimers() + } + }) it('fences synchronous producer data and duplicate exit while releasing an exiting PTY', () => { vi.useFakeTimers() try { diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index bce7b448d3c..ee1bc16c75b 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -131,6 +131,124 @@ describe('registerPtyHandlers', () => { await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' }) expect(providerSpawn).toHaveBeenCalledOnce() }) + it('adopts a runtime reservation created during renderer preflight', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + command?: string + sessionId?: string + tabId: string + leafId: string + env: Record + }): Promise<{ id: string }> + } + let resolveProviderSpawn!: (result: { id: string }) => void + const providerSpawn = vi.fn( + () => + new Promise<{ id: string }>((resolve) => { + resolveProviderSpawn = resolve + }) + ) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-concurrent'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + let releaseAuth!: (value: { + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }) => void + const prepareClaudeAuth = vi.fn( + () => + new Promise<{ + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }>((resolve) => { + releaseAuth = resolve + }) + ) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + prepareClaudeAuth + ) + const tabId = 'tab-mid-preflight-runtime' + const leafId = '55555555-5555-4555-8555-555555555555' + const paneKey = makePaneKey(tabId, leafId) + const paneArgs = { + cols: 80, + rows: 24, + sessionId: 'pty-runtime-reservation', + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey } + } + + const rendererSpawn = handlers.get('pty:spawn')!(null, { + ...paneArgs, + command: 'claude' + }) + await vi.waitFor(() => expect(prepareClaudeAuth).toHaveBeenCalledOnce()) + + const runtimeSpawn = (controller as unknown as RuntimeSpawnController).spawn({ + ...paneArgs, + command: 'printf runtime' + }) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) + releaseAuth({ + configDir: '/tmp/claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'managed:test' + }) + await new Promise((resolve) => setImmediate(resolve)) + resolveProviderSpawn({ id: 'pty-runtime-reservation' }) + + await expect(runtimeSpawn).resolves.toEqual({ id: 'pty-runtime-reservation' }) + await expect(rendererSpawn).resolves.toMatchObject({ + id: 'pty-runtime-reservation', + isReattach: true + }) + expect(providerSpawn).toHaveBeenCalledOnce() + await expect( + handlers.get('pty:getSize')!(null, { id: 'pty-runtime-reservation' }) + ).resolves.toEqual({ cols: 80, rows: 24 }) + }) it('reuses renderer spawn when runtime materialization starts for the same pane', async () => { type RuntimeSpawnController = { spawn(args: { diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index a203af2743e..7e526ea317e 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -408,6 +408,7 @@ describe('registerPtyHandlers', () => { const destroyedListeners: (() => void)[] = [] const sender = { id: 42, + isDestroyed: () => false, once: vi.fn((event: string, listener: () => void) => { if (event === 'destroyed') { destroyedListeners.push(listener) @@ -422,6 +423,20 @@ describe('registerPtyHandlers', () => { destroyedListeners[0]() expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) }) + it('does not retain a serializer declaration from an already-destroyed renderer', async () => { + registerPtyHandlers(mainWindow as never) + const paneKey = makePaneKey('tab-dead', '77777777-7777-4777-8777-777777777777') + const sender = { + id: 43, + isDestroyed: () => true, + once: vi.fn() + } + + await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey }) + + expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) + expect(sender.once).not.toHaveBeenCalled() + }) it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index e7377dbe927..97c0e614933 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -91,6 +91,21 @@ describe('registerPtyHandlers', () => { ).resolves.toBe(null) expect(hasPty).not.toHaveBeenCalled() }) + it.each(['remote:env-1@@terminal-1', 'ssh:missing-host@@pty-1'])( + 'never routes process inspection for %s to the local provider', + async (id) => { + const inspectProcess = vi.fn() + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ inspectProcess } as never) + + await expect(handlers.get('pty:inspectProcess')!(null, { id })).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + expect(inspectProcess).not.toHaveBeenCalled() + } + ) it('lists duplicate SSH relay session ids as distinct app sessions', async () => { registerPtyHandlers(mainWindow as never) const shutdownA = vi.fn(async () => undefined) diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index 219b3f94d9d..6e5e57f3ad6 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -5,6 +5,12 @@ import { createHash } from 'node:crypto' import { delimiter } from 'node:path' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { ClaudeAgentTeamsService } from '../runtime/claude-agent-teams-service' +import { + clearPaneSpawnReservation, + makePaneSpawnReservationKey, + reservePaneSpawn +} from './pty/pane/spawn-reservation' import { registerPtyHandlers, registerSshPtyProvider } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -500,4 +506,121 @@ describe('registerPtyHandlers', () => { id: spawned.id }) }) + it('releases the Agent Teams leader of a spawn that loses the pane mid-preflight', async () => { + const leafId = '22222222-2222-4222-8222-222222222222' + const teams = new ClaudeAgentTeamsService() + let handleSeq = 0 + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => `term_agent_teams_${++handleSeq}`), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + // Why: no worktreeId, so the reservation key only resolves after env assembly — the + // window where a concurrent winner's reservation appears mid-preflight. + const spawnArgs = { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + } + const winner = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs)) as { + id: string + } + expect(teams.getActiveTeamCount()).toBe(1) + + const reservationKey = makePaneSpawnReservationKey( + undefined, + undefined, + makePaneKey('tab-1', leafId) + )! + const winnerReservation = reservePaneSpawn(reservationKey) + winnerReservation.resolve({ id: winner.id }) + try { + const loser = await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs) + expect(loser).toMatchObject({ id: winner.id, isReattach: true }) + } finally { + clearPaneSpawnReservation(reservationKey, winnerReservation) + } + + const loserHandle = runtime.prepareClaudeAgentTeamsLeaderForHandle.mock.calls[1]?.[0].handle + expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledTimes(2) + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(loserHandle) + // The losing request must not grow the team count it can never bind to a PTY. + expect(teams.getActiveTeamCount()).toBe(1) + }) + it('releases the Agent Teams leader when its provider spawn fails', async () => { + const leafId = '33333333-3333-4333-8333-333333333333' + const teams = new ClaudeAgentTeamsService() + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams_failure'), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + spawnMock.mockImplementation(() => { + throw new Error('provider spawn failed') + }) + + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + }) + ).rejects.toThrow() + + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith( + 'term_agent_teams_failure' + ) + expect(teams.getActiveTeamCount()).toBe(0) + }) }) diff --git a/src/main/ipc/pty/delivery/debug-snapshot.ts b/src/main/ipc/pty/delivery/debug-snapshot.ts index 6767865868f..b718a979726 100644 --- a/src/main/ipc/pty/delivery/debug-snapshot.ts +++ b/src/main/ipc/pty/delivery/debug-snapshot.ts @@ -159,7 +159,7 @@ export function warnIfDroppingHiddenBytesForVisiblePty( } session.lastHiddenDropContradictionWarnAtMs = now console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { - id, + id: redactPtyIdForDiagnostics(id), droppedChars, visible: visibleRendererPtys.has(id), active: activeRendererPtys.has(id), diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts index 272ef52614c..119bb173ed7 100644 --- a/src/main/ipc/pty/delivery/payload.ts +++ b/src/main/ipc/pty/delivery/payload.ts @@ -37,15 +37,22 @@ export function sendModelRestoreNeededMarker( id: string, reason: PtyModelRestoreReason, markerSeq: number | undefined -): void { +): boolean { if (session.mainWindow.isDestroyed()) { - return + return false } - session.mainWindow.webContents.send('pty:modelRestoreNeeded', { - id, - reason, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}) - }) + try { + session.mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } catch (error) { + // Why: a disposed render frame throws synchronously here, and this rides the data path. + console.error('[pty] renderer model-restore marker send failed', error) + return false + } + return true } export function sendPtyDataToRenderer( @@ -114,21 +121,17 @@ export function sendPtyDataToRenderer( projectionsTransferred = true } } - if (session.rendererDeliveryRestoreNeededPtys.has(id)) { - try { - sendModelRestoreNeededMarker( - session, - id, - 'delivery-heal', - session.runtime?.getPtyOutputSequence(id) - ) - session.rendererDeliveryRestoreNeededPtys.delete(id) - } catch (error) { - console.error( - '[pty] renderer delivery-heal marker send failed; restore remains pending', - error - ) - } + if ( + session.rendererDeliveryRestoreNeededPtys.has(id) && + sendModelRestoreNeededMarker( + session, + id, + 'delivery-heal', + session.runtime?.getPtyOutputSequence(id) + ) + ) { + // Why cleared only on a successful send: an unsent marker leaves the restore pending. + session.rendererDeliveryRestoreNeededPtys.delete(id) } return { sent: true, projectionsTransferred } } diff --git a/src/main/ipc/pty/delivery/producer-sync.ts b/src/main/ipc/pty/delivery/producer-sync.ts index fddc3b4c36f..b718e3cebe7 100644 --- a/src/main/ipc/pty/delivery/producer-sync.ts +++ b/src/main/ipc/pty/delivery/producer-sync.ts @@ -42,8 +42,19 @@ export function syncPtyBackgroundedDelivery( known: rendererVisibilityKnownPtys.has(id), visible: visibleRendererPtys.has(id) }) + const previous = session.backgroundedDeliverySyncByPty.get(id) session.backgroundedDeliverySyncByPty.set(id, background) - provider.setPtyBackgrounded(id, background) + try { + provider.setPtyBackgrounded(id, background) + } catch (error) { + // Why: a recorded state the provider never took would short-circuit every later sync for this PTY. + if (previous === undefined) { + session.backgroundedDeliverySyncByPty.delete(id) + } else { + session.backgroundedDeliverySyncByPty.set(id, previous) + } + console.error('[pty] setPtyBackgrounded failed; delivery sync state rolled back', error) + } } export function resyncBackgroundedDeliveriesAfterGateReset(session: PtyIpcSession): void { diff --git a/src/main/ipc/pty/delivery/ssh-intake.ts b/src/main/ipc/pty/delivery/ssh-intake.ts index 46b2e4a6e3e..2f86608fb82 100644 --- a/src/main/ipc/pty/delivery/ssh-intake.ts +++ b/src/main/ipc/pty/delivery/ssh-intake.ts @@ -45,7 +45,9 @@ export function installSessionSshOutputIntake(session: PtyIpcSession): void { return release }, finalizeExit: (event) => { - session.runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation) + session.runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, { + hostExitConfirmed: true + }) session.finalizePtyExitForRenderer(event) }, pauseProvider: (generation, id) => { diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 1b00213635d..f4adb19b697 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -231,9 +231,18 @@ export function installPtyInspectIpcHandlers(deps: { } ) - ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => - inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) - ) + ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => { + // Why: same routing hazard as pty:hasPty — an unroutable id must read as unavailable, not as a local-provider answer or a raised IPC error. + if ( + typeof args?.id !== 'string' || + !args.id || + args.id.startsWith('remote:') || + !hasPtyProviderForInspection(args.id) + ) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } + } + return inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) + }) ipcMain.handle( 'pty:confirmForegroundProcess', diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index 341620da6be..c55606d9813 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -84,6 +84,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { }) // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. + ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { const provider = tryGetProviderForPty(args.id) if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { @@ -92,6 +93,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { }) // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. + ipcMain.removeAllListeners('pty:ackData') ipcMain.on( 'pty:ackData', (_event, args: { id: string; charCount?: number; processedChars?: number }) => { @@ -114,6 +116,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { } ) + ipcMain.removeAllListeners('pty:deliveryResyncResponse') ipcMain.on( 'pty:deliveryResyncResponse', (_event, args: { requestId: number; processedCharsByPty: Record }) => { @@ -142,6 +145,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { ) // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. + ipcMain.removeHandler('pty:reportRendererDeliveryState') ipcMain.handle( 'pty:reportRendererDeliveryState', (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts index 8f914e6d912..406096f2b70 100644 --- a/src/main/ipc/pty/ipc/serialize-buffer.ts +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' +import { isMainWindowPtyIpcEvent } from './write-input' import type { PtyIpcSession, SerializeResult } from '../session' export function settleSerializeRequest( @@ -22,7 +23,7 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void { ipcMain.on( 'pty:serializeBuffer:response', ( - _event, + event, args: { requestId?: string snapshot?: { @@ -35,7 +36,11 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void { } | null } ) => { - if (typeof args?.requestId !== 'string') { + // Why: the snapshot seeds terminal restore state, so only the main window may settle it. + if ( + !isMainWindowPtyIpcEvent(event, session.mainWindow, session.mainWindow.webContents) || + typeof args?.requestId !== 'string' + ) { return } const snapshot = args.snapshot diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 82a3aea1b74..7711ba3c3e1 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -25,6 +25,7 @@ import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx) + ctx.agentTeamsLeaderHandle = null // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index 8b43a7e54ca..2fd4c51ac04 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -89,6 +89,7 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise | undefined effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined preAllocatedHandle: string | null + /** Leader handle this spawn allocated Agent Teams state for; must be released if the spawn is abandoned. */ + agentTeamsLeaderHandle: string | null requestedAgentTeamsPath: string | undefined agentTeamsEnvToDelete: string[] | undefined stablePaneKey: string | null @@ -124,6 +126,7 @@ export function createPtyIpcSpawnState( baseEnv: undefined, effectiveLaunchConfig: undefined, preAllocatedHandle: null, + agentTeamsLeaderHandle: null, requestedAgentTeamsPath: undefined, agentTeamsEnvToDelete: undefined, stablePaneKey: null, diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 08e6d8e9905..fbfc09d1d4d 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -44,7 +44,6 @@ export function createPtyWriteInput(deps: { ) => boolean } { const { mainWindow, runtime, clearHiddenRendererResizeOutput } = deps - void mainWindow const reportUnavailablePtyWrite = (id: string, error: unknown): void => { if ( @@ -124,9 +123,6 @@ export function createPtyWriteInput(deps: { } } - type PtyWritePayload = { id: string; data: string } - type PtyViewportClaimPayload = { id: string; cols: number; rows: number } - const isPtyWritePayload = (value: unknown): value is PtyWritePayload => typeof value === 'object' && value !== null && diff --git a/src/main/ipc/pty/ipc/write.ts b/src/main/ipc/pty/ipc/write.ts index 8350afdb26d..42565065148 100644 --- a/src/main/ipc/pty/ipc/write.ts +++ b/src/main/ipc/pty/ipc/write.ts @@ -57,7 +57,11 @@ export function installPtyWriteIpcHandlers(deps: { () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) ) : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ).catch(() => false) + ).catch((error) => { + // Why: a failed claim silently discards every gated keystroke for this pane. + console.error('[pty] remote desktop host claim failed; gated input will be discarded', error) + return false + }) hostViewportClaimTails.set(args.id, claim) void claim.then(() => { if (hostViewportClaimTails.get(args.id) === claim) { diff --git a/src/main/ipc/pty/pane/agent-session-owners.ts b/src/main/ipc/pty/pane/agent-session-owners.ts index b706d5b9683..291eaab6462 100644 --- a/src/main/ipc/pty/pane/agent-session-owners.ts +++ b/src/main/ipc/pty/pane/agent-session-owners.ts @@ -14,6 +14,9 @@ import { // multiple SSH relays; coordinate claims above every provider boundary too. export const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() let agentSessionOwnerReconciliation: Promise | null = null +// Why: this reconcile gates spawn; a wedged relay listing must fail closed (rejecting +// with unknown ownership) instead of blocking the spawn path indefinitely. +const OWNER_LISTING_DEADLINE_MS = 5_000 // Why: restore payloads (reattach snapshot / cold-restore scrollback / relay // replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn @@ -67,10 +70,13 @@ export async function reconcileAgentSessionOwnerListings(): Promise { { provider: localProvider, connectionId: null }, ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) ] + const deadlineMs = Date.now() + OWNER_LISTING_DEADLINE_MS const listings = await Promise.all( providers.map(async ({ provider, connectionId }) => ({ connectionId, - sessions: await provider.listProcesses() + sessions: await provider.listProcesses({ deadlineMs }).catch(() => { + throw new Error('agent_session_ownership_unknown') + }) })) ) const advertisedOwners: AgentSessionOwnerBinding[] = [] diff --git a/src/main/ipc/pty/pane/key-state.ts b/src/main/ipc/pty/pane/key-state.ts index 721f9689ccf..0a7881140b6 100644 --- a/src/main/ipc/pty/pane/key-state.ts +++ b/src/main/ipc/pty/pane/key-state.ts @@ -34,6 +34,13 @@ export function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | if (!isValidPaneKey(normalizedPaneKey)) { return null } + // Why: a re-registered ptyId would otherwise leave its old paneKey pointing here. + const previousPaneKey = ptyPaneKey.get(ptyId) + if (previousPaneKey && previousPaneKey !== normalizedPaneKey) { + if (paneKeyPtyId.get(previousPaneKey) === ptyId) { + paneKeyPtyId.delete(previousPaneKey) + } + } ptyPaneKey.set(ptyId, normalizedPaneKey) paneKeyPtyId.set(normalizedPaneKey, ptyId) return normalizedPaneKey diff --git a/src/main/ipc/pty/pane/serializer-state.ts b/src/main/ipc/pty/pane/serializer-state.ts index 2730f2e8267..ec3f8dacbc4 100644 --- a/src/main/ipc/pty/pane/serializer-state.ts +++ b/src/main/ipc/pty/pane/serializer-state.ts @@ -39,6 +39,12 @@ export function declarePendingPaneSerializer( sender: WebContents | undefined ): number { const gen = ++pendingSerializerGenSeq + // Why: a sender destroyed before the declaration never fires 'destroyed' again, + // so its entry would strand in pendingByPaneKey and suppress the daemon seed forever. + if (sender?.isDestroyed()) { + cleanupPendingPaneSerializersForSender(sender.id) + return gen + } registerPendingPaneSerializerCleanup(sender) const replaced = pendingByPaneKey.get(paneKey) if (replaced) { diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 5db1de8efe7..a8c005cce06 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -110,10 +110,16 @@ export async function verifyPtyStopped( if (!opts?.keepHistory) { return true } - const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS + const settleDeadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS + // Why: deadlineMs is absolute, so the settle poll must not outlive the caller's teardown budget. + const deadline = + opts.deadlineMs !== undefined ? Math.min(settleDeadline, opts.deadlineMs) : settleDeadline while (Date.now() < deadline) { - await delay(KEEP_HISTORY_STOP_POLL_MS) - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { + await delay(Math.min(KEEP_HISTORY_STOP_POLL_MS, deadline - Date.now())) + if (Date.now() >= deadline) { + break + } + if (await isProviderPtyLive(provider, ptyId, deadline)) { return false } } diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts index 1072864dc4b..85c8a3514db 100644 --- a/src/main/ipc/pty/provider/registry.ts +++ b/src/main/ipc/pty/provider/registry.ts @@ -49,7 +49,12 @@ export function getProviderForPty(ptyId: string): IPtyProvider { export function hasPtyProviderForInspection(ptyId: string): boolean { // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. const connectionId = ptyOwnership.get(ptyId) - return connectionId == null || sshProviders.has(connectionId) + if (connectionId === undefined) { + // Why: mirror getProviderForPty — an unowned id still routes by its encoded SSH owner. + const parsedSshId = parseAppSshPtyId(ptyId) + return !parsedSshId || sshProviders.has(parsedSshId.connectionId) + } + return connectionId === null || sshProviders.has(connectionId) } export function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index ba4323520a5..98b08b42fed 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -32,6 +32,7 @@ import { setRendererGateResetState } from './provider/listener-lifecycle' import { + resetRendererDeliveryAccountingForLifecycleReset, setResetRendererDeliveryAccountingForLifecycleReset, clearRendererDispatcherReadyWatchdog } from './delivery/debug' @@ -65,6 +66,9 @@ export function registerPtyHandlers( store?: Store, options?: PtyIpcSessionOptions ): void { + // Why first: the outgoing session owns the producer pauses, so its real reset must run + // before the bridge is neutralized or a PTY paused during re-registration stays paused. + resetRendererDeliveryAccountingForLifecycleReset() // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. clearRendererDispatcherReadyWatchdog() setResetRendererDeliveryAccountingForLifecycleReset(() => {}) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 76c369a2d36..ecf4f4ebcd4 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -83,6 +83,13 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ...(ctx.env ? { launchEnv: ctx.env } : {}) }) } + // Why: the adopted branch returns before the normal settle site, so the + // reservation must be resolved here or every later spawn for this pane + // awaits a promise that never settles. + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + ...ctx.result, + isReattach: true + }) return { id: ctx.result.id, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index ed412153dcb..575f89b22e7 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -11,19 +11,23 @@ export function adoptMaterializedRuntimePtySpawn( startupAlreadyAwaited = false ): Promise | PtySpawnResult | null { const args = ctx.args - ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - ctx.codexHomeLaunchStartedSequence = !args.connectionId - ? allocatePtyLifecycleSequence() - : undefined - ctx.preAdoptedStablePane = args.adoptedStablePane ?? null - ctx.reattachedCodexHomeRoutes = !args.connectionId - ? new Map( - snapshotCodexPaneHomeRoutes([ - ctx.preAdoptedStablePane?.result.id, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - ) - : new Map() + // Why: the re-entry after the startup await must not re-mint the lifecycle + // sequence or re-snapshot routes taken before the barrier. + if (!startupAlreadyAwaited) { + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + ctx.preAdoptedStablePane = args.adoptedStablePane ?? null + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + ctx.preAdoptedStablePane?.result.id, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + } const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId) if (startupPromise && !startupAlreadyAwaited) { return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index eea2c2319f4..fcba7770a39 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -42,6 +42,7 @@ export async function buildRuntimePtySpawnOptions( rows: args.rows, cwd: ctx.cwd, env: ctx.env, + historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true, ...(ctx.isNewDaemonSession ? { isNewSession: true } : {}) } if (!args.connectionId && !ctx.isDaemonHostSpawn) { @@ -203,7 +204,7 @@ export async function buildRuntimePtySpawnOptions( ) { throw new Error('terminal_pane_owner_unknown') } - return { + const reattach = { id: concurrentOwner.ptyId, ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), stablePaneOwner: { @@ -212,6 +213,18 @@ export async function buildRuntimePtySpawnOptions( leafId: concurrentOwner.leafId } } + // Why: the winner owns its id's size; only a distinct losing session id remains provisional. + if (ctx.sessionId !== undefined) { + const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.sessionId + if (provisionalSizeKey !== reattach.id) { + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(provisionalSizeKey) + } + } + } + return reattach } } ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 784e4c78a7e..48768a31dc4 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -8,6 +8,7 @@ import { reservePaneSpawn } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' +import { ptySizes } from '../delivery/visibility-state' import type { PtyRuntimeControllerDeps } from './controller-deps' import { adoptMaterializedRuntimePtySpawn } from './spawn-early' import { prepareRuntimePtySpawn } from './spawn-preflight' @@ -32,6 +33,18 @@ function toRuntimeSpawnReply(result: { } } +function restoreProvisionalPtySize(ctx: ReturnType): void { + if (ctx.sessionId === undefined) { + return + } + const key = ctx.effectiveSessionAppId ?? ctx.sessionId + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(key, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(key) + } +} + export async function spawnPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, args: RuntimePtySpawnArgs @@ -63,24 +76,25 @@ export async function spawnPtyFromRuntimeController( ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) } } - const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) - const materialized = - materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise - if (materialized) { - return toRuntimeSpawnReply(materialized) - } - const earlyAdopt = await prepareRuntimePtySpawn(ctx) - if (earlyAdopt) { - return toRuntimeSpawnReply(earlyAdopt) - } - const earlyReserved = await buildRuntimePtySpawnOptions(ctx) - if (earlyReserved) { - return toRuntimeSpawnReply(earlyReserved) - } try { + const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) + const materialized = + materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise + if (materialized) { + return toRuntimeSpawnReply(materialized) + } + const earlyAdopt = await prepareRuntimePtySpawn(ctx) + if (earlyAdopt) { + return toRuntimeSpawnReply(earlyAdopt) + } + const earlyReserved = await buildRuntimePtySpawnOptions(ctx) + if (earlyReserved) { + return toRuntimeSpawnReply(earlyReserved) + } await executeRuntimePtySpawn(ctx) return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx)) } catch (err) { + restoreProvisionalPtySize(ctx) if (ctx.pendingRegistrationPtyId) { deps.runtime?.cancelPendingPtyRegistration?.( ctx.pendingRegistrationPtyId, diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index a2e975de806..423ecabbe3d 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -115,7 +115,7 @@ export type PtyIpcSession = { id: string, reason: PtyModelRestoreReason, markerSeq: number | undefined - ) => void + ) => boolean updateProducerFlowControl: (id: string) => void applyCumulativeAck: (id: string, processedChars: number) => number readCurrentPtyRendererDeliveryDebugSnapshot: () => PtyRendererDeliveryDebugSnapshot diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 60a20f7929f..f3d76bfca8f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -30341,6 +30341,12 @@ export class OrcaRuntimeService { }) } + // Why: a leader handle that never binds to a PTY (lost pane race) has no exit + // or close path to evict its team, so the abandoning caller must release it. + releaseClaudeAgentTeamsLeaderForHandle(handle: string): void { + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + } + private waitForNewLeafInTab( tabId: string, existingLeafKeys: Set,