mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Keep sleeping agent panes resumable in place
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user