mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(orchestration): preserve slept agent wake recovery
This commit is contained in:
@@ -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())
|
||||
|
||||
+119
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user