Keep sleeping agent panes resumable in place

This commit is contained in:
brennanb2025
2026-06-25 23:13:47 -07:00
parent d0d257189c
commit bde813fd50
3 changed files with 227 additions and 16 deletions
+94
View File
@@ -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,
@@ -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<string>()
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 () => {
@@ -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()