From bde813fd50ae0fa9e0776775bf704db581d8410c Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 25 Jun 2026 23:13:47 -0700 Subject: [PATCH] Keep sleeping agent panes resumable in place --- src/main/runtime/orca-runtime.test.ts | 94 ++++++++++++++ .../terminal-pane/pty-connection.test.ts | 122 ++++++++++++++++-- .../terminal-pane/pty-connection.ts | 27 ++-- 3 files changed, 227 insertions(+), 16 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fbfe3948c62..318c7a7f0d3 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1378,6 +1378,100 @@ describe('OrcaRuntimeService', () => { expect(shown.ptyId).toBe('pty-1') }) + it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => { + const runtime = new OrcaRuntimeService(store) + const tabId = 'tab-1' + const leafId = HEADLESS_LEAF_ID + const paneKey = makePaneKey(tabId, leafId) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-before-sleep' + } + ] + }) + + const beforeSleep = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + const staleHandle = beforeSleep.terminals[0]?.handle ?? '' + expect(staleHandle).toBeTruthy() + + // Why: `terminal.show` is read-only; only the later renderer wake/rebind + // graph publish can repair the CLI handle surface for this pane. + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: null + } + ] + }) + + await expect(runtime.showTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale') + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-after-wake' + } + ] + }) + runtime.onPtyData('pty-after-wake', 'resumed in place\n', 123) + + const resolved = runtime.resolveTerminalPane(paneKey) + expect(resolved).toMatchObject({ + tabId, + leafId, + ptyId: 'pty-after-wake' + }) + await expect(runtime.showTerminal(resolved.handle)).resolves.toMatchObject({ + handle: resolved.handle, + tabId, + leafId, + ptyId: 'pty-after-wake', + preview: 'resumed in place' + }) + }) + it('keeps targeted terminal lists from adopting controller PTYs for other worktrees', async () => { vi.mocked(listWorktrees).mockResolvedValue([ ...MOCK_GIT_WORKTREES, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 0b63a45b794..6b7131c73c5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -811,6 +811,9 @@ describe('connectPanePty', () => { onPtyExit?.('pty-pane-2') expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2') + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, null) + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-pane-2') expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() expect(manager.closePane).not.toHaveBeenCalled() }) @@ -4362,6 +4365,109 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) + it('resumes a local sleeping pane in place when the restored PTY hint is missing', async () => { + const { connectPanePty } = await import('./pty-connection') + const spawn = createDeferred() + const transport = createMockTransport() + transport.connect.mockImplementation(() => spawn.promise) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_2) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null }] + }, + ptyIdsByTabId: { + 'tab-1': [] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(2) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: {} + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(4) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + launchAgent: 'codex', + env: expect.objectContaining({ + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_WORKSPACE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + ) + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ sessionId: expect.any(String) }) + ) + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith( + paneKey, + { + agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'", + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + }, + { + agentType: 'codex', + launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), + tabId: 'tab-1', + leafId: LEAF_2 + } + ) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('fresh-pty') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-pty') + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-pty') + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() + + spawn.resolve('fresh-pty') + await flushAsyncTicks(10) + + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) + expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2) + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + }) + it('keeps sleeping resume record when fresh cold-restore spawn fails', async () => { const { connectPanePty } = await import('./pty-connection') const staleSessionId = 'wt-1@@stale-session' @@ -4401,18 +4507,18 @@ describe('connectPanePty', () => { } } as StoreState - connectPanePty( - createPane(2) as never, - createManager(2) as never, - createDeps({ - restoredLeafId: LEAF_2, - restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId } - }) as never - ) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: staleSessionId } + }) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) await flushAsyncTicks(20) expect(transport.connect).toHaveBeenCalledTimes(2) + expect(deps.onShowSessionRestoredBanner).not.toHaveBeenCalled() expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) it('does not write the restored banner through xterm bytes for sidebar-resumed startup commands', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 72511d14949..42b47b7a89c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1292,9 +1292,7 @@ export function connectPanePty( agentCompletionCoordinator.dispose() clearPanePtyFitBinding() const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId) - if (isSuppressedExit) { - deps.syncPanePtyLayoutBinding(pane.id, null) - } else { + if (!isSuppressedExit) { deps.clearExitedPanePtyLayoutBinding(pane.id, ptyId) } deps.clearRuntimePaneTitle(deps.tabId, pane.id) @@ -1315,6 +1313,8 @@ export function connectPanePty( // suppression here, split-pane Codex restarts would still close the pane // because this handler runs before the tab-level close logic sees the exit. if (isSuppressedExit) { + // Why: the action that suppressed the exit owns whether the leaf binding + // is a wake hint or should be discarded; runtime cleanup above is enough. manager.setPaneGpuRendering(pane.id, true) return } @@ -2279,9 +2279,6 @@ export function connectPanePty( return false } const state = useAppStore.getState() - if (startup.hasSleepingRecord) { - showSessionRestoredBanner() - } state.registerAgentLaunchConfig(cacheKey, startup.launchConfig, { agentType: startup.agent, launchToken: startup.launchToken, @@ -2440,6 +2437,9 @@ export function connectPanePty( }) } if (resolvedPtyId) { + if (coldRestoreOverride?.hasSleepingRecord) { + showSessionRestoredBanner() + } clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) } else if ( paneStartup?.launchConfig || @@ -3524,6 +3524,9 @@ export function connectPanePty( const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) if (didPrepareResume) { + if (preparedStartup?.hasSleepingRecord) { + showSessionRestoredBanner() + } clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) } // Cold-restore means the daemon lost the session and spawned a @@ -4041,7 +4044,11 @@ export function connectPanePty( `Pending PTY spawn for tab ${deps.tabId} resolved without a PTY id, retrying fresh spawn` ) } - startFreshSpawn() + if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { + startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) + } else { + startFreshSpawn() + } return } // Why: this attach path reuses a PTY spawned by an earlier mount. @@ -4070,7 +4077,11 @@ export function connectPanePty( }) } else { recordPtyConnectDiagnostic(`pane=${pane.id} -> FRESH SPAWN`) - startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup) + if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { + startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) + } else { + startFreshSpawn() + } } } scheduleRuntimeGraphSync()