From fae2accf64ef0d6bbd8209c44c2c997d04844e45 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 16:41:55 -0700 Subject: [PATCH] fix(orchestration): preserve slept agent wake recovery --- .../orca-runtime-wait-for-leaf-pty-id.ts | 5 + src/main/runtime/slept-pane-mail-wake.test.ts | 28 +++++ ...nnection-cold-restore-agent-resume.test.ts | 119 ++++++++++++++++++ .../pty-connection/sleeping-record-access.ts | 29 ++++- .../agent-status-manual-sleep-capture.test.ts | 22 ++++ .../slices/agent-status-sleeping-records.ts | 7 ++ 6 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 15f0b0e08b9..460d948a60b 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -110,6 +110,11 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP if (!args.worktreeId || (!args.tabId && !args.ptyId)) { return false } + // Why: webContents.send can accept the event while a renderer reload has no + // graph/listener to consume it; report failure so graph-ready redrives mail wakes. + if (isInboundMessageTabMount(args.intent) && this.graphStatus !== 'ready') { + return false + } // Why: opening a tab is the documented wake gesture for a pane the user slept // (#11598), so only an inbound message may be refused for one. if ( diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 774e56065a3..b873e745d3a 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -169,6 +169,34 @@ describe('mail addressed to a listed slept pane', () => { } }) + it('does not lose a wake sent while the renderer graph is reloading', async () => { + vi.useFakeTimers() + try { + const { runtime, db, handle, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + expect(runtime.markRendererReloading(1)).not.toBeNull() + db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(1_500) + + expect(tabMountSends).toEqual([]) + + runtime.markGraphReady(1) + await vi.advanceTimersByTimeAsync(1_500) + + expect(tabMountSends).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + it('wakes the pane the listing just made addressable', async () => { const { runtime, db, handle, resumable, connected, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index 35b37855238..0c51d5465fc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -402,6 +402,125 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) + it('resumes from a sleeping record after the same stable leaf moves to a reminted tab', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const oldPaneKey = makePaneKey('tab-obsolete', LEAF_1) + const currentPaneKey = makePaneKey('tab-reminted', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-reminted', ptyId: 'lost-pty' }] + }, + ptyIdsByTabId: { + 'tab-reminted': ['lost-pty'] + }, + terminalLayoutsByTabId: { + 'tab-reminted': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'lost-pty' } + } + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [oldPaneKey]: { + paneKey: oldPaneKey, + tabId: 'tab-obsolete', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-reminted' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + tabId: 'tab-reminted', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-reminted'", + resumeProviderSession: { key: 'session_id', id: 'codex-session-reminted' }, + env: expect.objectContaining({ + ORCA_PANE_KEY: currentPaneKey, + ORCA_TAB_ID: 'tab-reminted' + }) + }) + ) + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(oldPaneKey) + }) + + it('does not guess between conflicting sleeping sessions for a reminted stable leaf', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transportFactoryQueue.push(transport) + const firstPaneKey = makePaneKey('tab-obsolete-a', LEAF_1) + const secondPaneKey = makePaneKey('tab-obsolete-b', LEAF_1) + const sleepingRecord = (paneKey: string, tabId: string, sessionId: string) => ({ + paneKey, + tabId, + worktreeId: 'wt-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: sessionId }, + prompt: 'finish the task', + state: 'working' as const, + capturedAt: 1, + updatedAt: 1 + }) + mockStoreState = { + ...mockStoreState, + sleepingAgentSessionsByPaneKey: { + [firstPaneKey]: sleepingRecord(firstPaneKey, 'tab-obsolete-a', 'session-a'), + [secondPaneKey]: sleepingRecord(secondPaneKey, 'tab-obsolete-b', 'session-b') + } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ command: expect.stringContaining('resume') }) + ) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + }) + it('marks the pane as freshly started when main declined an unverifiable resume', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 0777dead431..988c408e6b0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -1,7 +1,7 @@ import { useAppStore } from '@/store' import type { PtyConnectResult } from '../pty-transport' import { createBrowserUuid } from '@/lib/browser-uuid' -import { parseLegacyNumericPaneKey } from '../../../../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../../shared/stable-pane-id' import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership' import { agentProviderSessionsEqual, @@ -27,6 +27,33 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi if (stableRecord) { return { paneKey: session.cacheKey, record: stableRecord } } + const currentStablePane = parsePaneKey(session.cacheKey) + if (currentStablePane) { + const stableMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( + ([paneKey, record]) => { + const candidate = parsePaneKey(paneKey) + return ( + candidate?.leafId === currentStablePane.leafId && + record.worktreeId === session.deps.worktreeId && + (!record.tabId || record.tabId === candidate.tabId) + ) + } + ) + const providerSessionKeys = new Set( + stableMatches.map(([, record]) => getProviderSessionClaimKey(record)) + ) + if (providerSessionKeys.size === 1) { + const selected = stableMatches + .slice() + .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] + if (selected) { + const [paneKey, record] = selected + return { paneKey, record } + } + } else if (stableMatches.length > 0) { + return null + } + } const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( ([paneKey, record]) => { const legacy = parseLegacyNumericPaneKey(paneKey) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts index f1deb30370f..1a5886adc0e 100644 --- a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts @@ -141,6 +141,28 @@ describe('manual sleep agent session capture', () => { expect(records['tab-1:working'].restoreOnTabOpenOnly).toBeUndefined() }) + it('marks a retained auto-hibernation record for tab-open-only restore on manual sleep', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + sleepingAgentSessionsByPaneKey: { + 'tab-1:done': makeSleepingRecord({ + paneKey: 'tab-1:done', + state: 'done', + origin: 'worktree-sleep' + }) + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:done'].restoreOnTabOpenOnly).toBe( + true + ) + }) + it('carries a blocked legacy-orchestration-worker flag onto the replacement record', () => { vi.useFakeTimers() vi.setSystemTime(NOW) diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 90a8b5b196b..3db8ee5fcc5 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -137,6 +137,13 @@ export function removeSleepingRecordsReplacedByManualWorktreeSleep( // Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the // pane was never woken, so it has no live status row to rebuild it from (#11598). if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) { + if (record.state === 'done' && record.restoreOnTabOpenOnly !== true) { + if (next === records) { + next = { ...records } + } + next[paneKey] = { ...record, restoreOnTabOpenOnly: true } + changed = true + } continue } if (next === records) {