fix(orchestration): preserve slept agent wake recovery

This commit is contained in:
Merge Sim
2026-09-04 16:41:55 -07:00
parent 959116fe4c
commit fae2accf64
6 changed files with 209 additions and 1 deletions
@@ -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 (
@@ -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())
@@ -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')
@@ -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)
@@ -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<AppState>)
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)
@@ -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) {