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,