diff --git a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts new file mode 100644 index 00000000000..6e03d15180b --- /dev/null +++ b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts @@ -0,0 +1,168 @@ +// A layout-owner build: a hook status main accepted before a workspace sleep must not replace the +// sleep's record when its queued write lands after the sleep is saved. +import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { LayoutApplyOutcome } from '../../shared/workspace-layout/workspace-layout-command-types' +import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission' +import type { WorktreeSleepResumeCapture } from './terminal/stop/worktree-sleep-resume-records' + +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures. +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} = await import('./orca-runtime-test-fixtures.spec') + +const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` +const PTY_ID = 'pty-slept' +const SESSION = { key: 'session_id' as const, id: 'codex-session-1' } + +function codexRow(overrides: Partial = {}): AgentStatusIpcPayload { + return { + paneKey: PANE_KEY, + worktreeId: TEST_WORKTREE_ID, + agentType: 'codex', + providerSession: SESSION, + state: 'working', + prompt: 'keep working', + receivedAt: 10, + stateStartedAt: 10, + connectionId: null, + ...overrides + } +} + +/** A runtime whose first record write applies at once but holds its durable write open. */ +function makeRuntimeWithSlowFirstWrite() { + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + let releaseFirstWrite = (): void => {} + const firstWriteDurable = new Promise((resolve) => { + releaseFirstWrite = resolve + }) + let writes = 0 + const applyLayoutCommand = vi.fn( + async ( + command: Parameters[2], + hostIds: ExecutionHostId[] + ): Promise => { + for (const hostId of hostIds) { + const change = applyCommandToSession(hostId, getSession(), command) + if (change.session) { + setSession(change.session) + } + } + writes += 1 + if (writes === 1) { + await firstWriteDurable + } + return { ok: true, result: {} } + } + ) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store implements the runtime store methods these tests reach. + Object.assign(runtimeStore, { applyLayoutCommand }) as never + ) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return { runtime, getSession, releaseFirstWrite } +} + +function sleepCapture(updatedAt: number): WorktreeSleepResumeCapture { + return new Map([ + [ + PTY_ID, + { + retiredPaneKeys: [], + records: [ + { + paneKey: PANE_KEY, + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: SESSION, + prompt: 'keep working', + state: 'done', + capturedAt: 20, + updatedAt, + origin: 'worktree-sleep', + restoreOnTabOpenOnly: true, + connectionId: null + } + ] + } + ] + ]) +} + +describe('a queued hook status and a workspace sleep', () => { + it('keeps the sleep’s record when a status accepted before the sleep is written after it', async () => { + const { runtime, getSession, releaseFirstWrite } = makeRuntimeWithSlowFirstWrite() + // An earlier status's write is still on its way to disk when the next status is accepted. + runtime.recordHookStatusResume(codexRow()) + await vi.waitFor(() => + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeDefined() + ) + runtime.recordHookStatusResume(codexRow({ state: 'blocked', receivedAt: 11 })) + + // The sleep captured that status from the hook store and saves its record. + const slept = runtime['sleepResumeRecords'].persist(TEST_WORKTREE_ID, sleepCapture(11), [ + PTY_ID + ]) + releaseFirstWrite() + await slept + await runtime['hookStatusResumeWrites'] + + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toMatchObject({ + origin: 'worktree-sleep', + state: 'done', + restoreOnTabOpenOnly: true + }) + }) + + it('keeps a window sleep’s record when a status accepted before it is written after it', async () => { + const { runtime, getSession, releaseFirstWrite } = makeRuntimeWithSlowFirstWrite() + runtime.recordHookStatusResume(codexRow()) + await vi.waitFor(() => + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeDefined() + ) + runtime.recordHookStatusResume(codexRow({ state: 'blocked', receivedAt: 11 })) + + const [record] = sleepCapture(11).get(PTY_ID)!.records + const slept = runtime.applyLayoutCommand({ + type: 'sleep', + workspace: TEST_WORKTREE_ID, + tabId: 'host-tab', + paneKeys: [PANE_KEY], + records: [record!] + }) + releaseFirstWrite() + await slept + await runtime['hookStatusResumeWrites'] + + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toMatchObject({ + origin: 'worktree-sleep', + state: 'done' + }) + }) + + it('lets a status accepted after the sleep is saved replace its record, as the window does', async () => { + const { runtime, getSession, releaseFirstWrite } = makeRuntimeWithSlowFirstWrite() + releaseFirstWrite() + await runtime['sleepResumeRecords'].persist(TEST_WORKTREE_ID, sleepCapture(11), [PTY_ID]) + + runtime.recordHookStatusResume(codexRow({ receivedAt: 30 })) + await runtime['hookStatusResumeWrites'] + + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toMatchObject({ + origin: 'live', + state: 'working' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5c752f0f3b1..545c3e5db4f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7,6 +7,7 @@ import { } from './sidebar-inputs/orca-runtime-sidebar-temporal' import { OrcaRuntimeWithMigrationCatalog } from './orca-runtime-migration-catalog' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import type { AgentLaunchTabPublished, @@ -252,18 +253,24 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { /** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */ async applyLayoutCommand(command: WindowLayoutCommand): Promise { - const reply = await applyWindowLayoutCommand( - this.requireStore(), - (worktreeId, tabId) => { - if (tabId !== null) { - return this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) - } - const routed = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) - return routed ? [routed] : [] - }, - command, - (move) => rekeyMovedPaneOwners(this, move) - ) + const apply = (): Promise => + applyWindowLayoutCommand( + this.requireStore(), + (worktreeId, tabId) => { + if (tabId !== null) { + return this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) + } + const routed = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + return routed ? [routed] : [] + }, + command, + (move) => rekeyMovedPaneOwners(this, move) + ) + // A window's sleep or wake lands after the hook statuses main accepted before it, as in the window. + const reply = await (ORCA_LAYOUT_OWNER_IS_MAIN && + (command.type === 'sleep' || command.type === 'wake') + ? this.inResumeRecordOrder(apply) + : apply()) // Sent before the reply, so the window can wait for the frame that shows the change. return { ...reply, frameSeq: this.workspaceLayoutStream.settledSeq() } } diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts index 5283f4afd0f..bd91aeb9fc5 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts @@ -20,6 +20,7 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-sessio import type { ExecutionHostId } from '../../../../shared/execution-host' import { parseExecutionHostId } from '../../../../shared/execution-host' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRuntimeWithStopTerminalsForWorktree { @@ -41,11 +42,14 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu capturedAt: Date.now() }), persist: async (worktreeId, capture, stoppedPtyIds) => { - try { - await this.commitWorkspaceSessionEdit( + const commit = (): Promise => + this.commitWorkspaceSessionEdit( this.getWorkspaceSessionHostIdForWorktree(worktreeId), (session) => applyWorktreeSleepResumeRecords(session, capture, stoppedPtyIds) ) + try { + // Switch point: a status accepted before the sleep is saved is written before it, as in the window. + await (ORCA_LAYOUT_OWNER_IS_MAIN ? this.inResumeRecordOrder(commit) : commit()) } catch (error) { // Why: the PTYs already stopped; a lost record costs the resume, not the sleep. console.error('[runtime] could not record slept agent sessions', { worktreeId, error }) @@ -77,9 +81,19 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu return } this.previousHookStatusByPaneKey.set(row.paneKey, status) - this.hookStatusResumeWrites = this.hookStatusResumeWrites - .then(() => this.writeHookStatusResume(status)) - .catch((error) => console.warn('[runtime] could not record an agent resume record', error)) + this.inResumeRecordOrder(() => this.writeHookStatusResume(status)).catch((error) => + console.warn('[runtime] could not record an agent resume record', error) + ) + } + + /** Runs `write` after every resume record write queued before it. */ + protected inResumeRecordOrder(write: () => Promise): Promise { + const written = this.hookStatusResumeWrites.then(write) + this.hookStatusResumeWrites = written.then( + () => {}, + () => {} + ) + return written } /** A cleared status leaves the record alone (lost contact is no evidence), as in the window. */ @@ -113,6 +127,7 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu launchConfigOf: this.paneLaunchConfigOf }) if (command) { + // Not this.applyLayoutCommand: this runs inside the resume-record queue, which that would await. await applyWindowLayoutCommand(this.store, hostIdsFor, command) } }