mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
Main writes a workspace sleep's resume records after the statuses it accepted before the sleep (dark, layout-owner build only) (#30482)
* Main keeps an agent's resume record from the status its hook server accepts (dark, layout-owner build only) In a layout-owner build, the window's recorder and quit capture kept only records of tabs the window holds, so an agent in a workspace the window never opened lost its resume record at quit. Main now applies the window's rule (moved into src/shared) to every hook status for a pane it holds, and captures its own panes' agents at quit; the window's quit capture is gone. Co-Authored-By: Claude <noreply@anthropic.com> * Main writes a workspace sleep's resume records after the hook statuses it accepted before the sleep (dark, layout-owner build only) * A snapshot replay looks up a pane's tab only when its status can leave a resume record Restores the lookup order the window had before the record builder moved to shared code, and updates the two handler tests for the quit-records option. * Note why the status write applies its command directly --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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> = {}): 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<void>((resolve) => {
|
||||
releaseFirstWrite = resolve
|
||||
})
|
||||
let writes = 0
|
||||
const applyLayoutCommand = vi.fn(
|
||||
async (
|
||||
command: Parameters<typeof applyCommandToSession>[2],
|
||||
hostIds: ExecutionHostId[]
|
||||
): Promise<LayoutApplyOutcome> => {
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<LayoutApplyReply> {
|
||||
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<LayoutApplyReply> =>
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -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<void> =>
|
||||
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<T>(write: () => Promise<T>): Promise<T> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user