diff --git a/src/main/runtime/restored-pane-hook-store-resume.test.ts b/src/main/runtime/restored-pane-hook-store-resume.test.ts new file mode 100644 index 00000000000..770700c32e9 --- /dev/null +++ b/src/main/runtime/restored-pane-hook-store-resume.test.ts @@ -0,0 +1,201 @@ +// verify-15a parity: a host whose daemon restarted resumed nothing and replayed the pane's launch. +import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { partialRuntimeStore } from './runtime-store-partial-fixture' + +// Preserve side-effect order: mocks, lifecycle, 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 { committingSpawn } = await import('./bind-first-pane.test-fixture') + +const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` +const SESSION_ID = '0bc9e9f8-192c-4bd4-beb8-8b745c59c8b0' +const RESUMES = new RegExp(`resume'? '?${SESSION_ID}`) + +function codexRow(fields: Partial = {}): AgentStatusIpcPayload { + return { + paneKey: PANE_KEY, + agentType: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID }, + prompt: 'hello p7', + state: 'done', + receivedAt: 1, + stateStartedAt: 1, + connectionId: null, + ...fields + } +} + +function makeRuntime( + rows: AgentStatusIpcPayload[], + launchAgent?: 'codex', + { + session: overrides = {}, + presence = 'exited' + }: { + session?: Partial + presence?: 'live' | 'exited' + } = {} +) { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const [tab] = session.tabsByWorktree[TEST_WORKTREE_ID] ?? [] + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...session, + tabsByWorktree: { [TEST_WORKTREE_ID]: [{ ...tab!, ...(launchAgent ? { launchAgent } : {}) }] }, + ...overrides + }) + const spawn = vi.fn( + async (_args: { worktreeId?: string; tabId?: string; leafId?: string; command?: string }) => ({ + id: 'restored-pty' + }) + ) + const runtime = new OrcaRuntimeService(partialRuntimeStore(runtimeStore), undefined, { + getAgentProviderSessionSnapshot: () => rows, + getAgentProviderSessionRowsForPane: (paneKey: string) => + rows.filter((row) => row.paneKey === paneKey), + checkHookAgentPresence: async () => presence + }) + runtime.setPtyController({ + spawn: committingSpawn(runtime, spawn), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return { runtime, spawn } +} + +/** Main's fresh launch: a new conversation, which can't collide with the vetoed one. */ +function expectFreshLaunch(command: string | undefined): void { + expect(command ?? '').not.toMatch(RESUMES) + expect(command).toContain('codex') +} + +async function restore(runtime: InstanceType): Promise { + await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', HEADLESS_LEAF_ID, { + notifyClients: false, + navigation: 'caller', + intent: 'automatic' + }) +} + +describe('restoring a pane its host recorded an agent in', () => { + it('resumes the hook store’s conversation instead of replaying the launch command', async () => { + const { runtime, spawn } = makeRuntime([codexRow()], 'codex') + await restore(runtime) + expect(spawn).toHaveBeenCalledOnce() + expect(spawn.mock.calls[0]![0].command).toMatch(RESUMES) + }) + + it('resumes an agent typed into a shell, which no launch command names', async () => { + const { runtime, spawn } = makeRuntime([codexRow()]) + await restore(runtime) + expect(spawn.mock.calls[0]![0].command).toMatch(RESUMES) + }) + + it('resumes an agent whose process exit left only its resume identity', async () => { + const { runtime, spawn } = makeRuntime([codexRow({ providerSessionOnly: true })], 'codex') + await restore(runtime) + expect(spawn.mock.calls[0]![0].command).toMatch(RESUMES) + }) + + it('never resumes a conversation a relayed SSH row names', async () => { + const { runtime, spawn } = makeRuntime([codexRow({ connectionId: 'ssh-1' })]) + await restore(runtime) + expect(spawn.mock.calls[0]![0].command ?? '').not.toContain('resume') + }) + + it('ignores a row from another worktree, and launches the tab’s agent fresh', async () => { + const { runtime, spawn } = makeRuntime( + [codexRow({ worktreeId: 'other-repo::/other' })], + 'codex' + ) + await restore(runtime) + const { command } = spawn.mock.calls[0]![0] + expect(command ?? '').not.toMatch(RESUMES) + expect(command).toContain('codex') + }) + + it('launches the tab’s agent fresh when the pane’s own rows name two conversations', async () => { + const { runtime, spawn } = makeRuntime( + [ + codexRow({ receivedAt: 2 }), + codexRow({ providerSession: { key: 'session_id', id: 'another-session' }, receivedAt: 1 }) + ], + 'codex' + ) + await restore(runtime) + expectFreshLaunch(spawn.mock.calls[0]![0].command) + }) + + it('launches the tab’s agent fresh when another pane’s stored record holds the conversation', async () => { + const otherPaneKey = 'other-tab:44444444-4444-4444-8444-444444444444' + const { runtime, spawn } = makeRuntime([codexRow()], 'codex', { + session: { + sleepingAgentSessionsByPaneKey: { + [otherPaneKey]: { + paneKey: otherPaneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'other-tab', + agent: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID }, + state: 'done', + prompt: '', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + } + } + }) + await restore(runtime) + expectFreshLaunch(spawn.mock.calls[0]![0].command) + }) + + it('launches the tab’s agent fresh when another pane holds the conversation live', async () => { + const otherPaneKey = 'other-tab:44444444-4444-4444-8444-444444444444' + const { runtime, spawn } = makeRuntime( + [codexRow(), codexRow({ paneKey: otherPaneKey })], + 'codex', + { presence: 'live' } + ) + vi.spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's protected paneHoldsSession; the other pane stands in for a bound live terminal. + runtime as unknown as { paneHoldsSession: (paneKey: string) => boolean }, + 'paneHoldsSession' + ).mockImplementation((paneKey) => paneKey === otherPaneKey) + await restore(runtime) + expectFreshLaunch(spawn.mock.calls[0]![0].command) + }) + + it('falls back to the tab’s agent launch when the newest row is a structured host’s', async () => { + const { runtime, spawn } = makeRuntime([codexRow({ structuredHost: 'owned' })], 'codex') + await restore(runtime) + const { command } = spawn.mock.calls[0]![0] + expect(command ?? '').not.toMatch(RESUMES) + expect(command).toContain('codex') + }) + + it('resumes a pane whose only other row is a relayed SSH row naming another session', async () => { + const { runtime, spawn } = makeRuntime( + [ + codexRow(), + codexRow({ + providerSession: { key: 'session_id', id: 'remote-session' }, + connectionId: 'ssh-1', + receivedAt: 2 + }) + ], + 'codex' + ) + await restore(runtime) + expect(spawn.mock.calls[0]![0].command).toMatch(RESUMES) + }) +}) diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts index 387b4a32f7a..02443ba35dd 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-ownership.ts @@ -1,6 +1,7 @@ // Share recorded-session ownership checks and in-flight claims between pane reopen and targeted resume. import { findPaneSleepingRecord } from './pane-sleeping-record' +import { hookRowPaneResumeRecord, isPaneLocalHookRow } from './hook-row-pane-resume-record' import { makePaneKey } from '../../../../shared/stable-pane-id' import { OrcaRuntimeWithApplyMobileSessionTabNavigation } from './apply-mobile-session-tab-navigation' import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types' @@ -63,17 +64,32 @@ export abstract class OrcaRuntimeWithAgentSessionResumeOwnership extends OrcaRun /** * The pane's resume launch: undefined when it owns no record, `{}` (plain shell, record kept) - * when the record cannot be resumed here or another resume took it. + * when a stored record cannot be resumed here or another resume took it. A record read from the + * hook store is no promise to keep: its vetoes give undefined, so the tab's agent launches fresh. */ protected async resolvePaneOwnedResumeStartup( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): Promise | undefined> { const stored = this.getPaneSleepingAgentRecord(worktreeId, tab) - const record = stored && this.isPaneOwnedResumeRecord(stored) ? stored : null + const record = stored + ? this.isPaneOwnedResumeRecord(stored) + ? stored + : null + : // Resume the hook store's conversation, never replay the pane's original launch. + hookRowPaneResumeRecord({ + rows: + this.getAgentProviderSessionRowsForPaneFn?.(makePaneKey(tab.parentTabId, tab.leafId)) ?? + [], + worktreeId, + tab: this.getWorkspaceSessionForWorktree(worktreeId)?.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === tab.parentTabId + ) + }) if (!record) { return undefined } + const vetoed = stored ? {} : undefined // Records are unspent here; exclude aliases and already-live panes to prevent duplicate transcript writers. const records = this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey const aliases = this.findPaneSleepingAgentRecord(worktreeId, tab)?.aliases ?? [] @@ -89,23 +105,24 @@ export abstract class OrcaRuntimeWithAgentSessionResumeOwnership extends OrcaRun ? null : this.agentSessionResumeClaims.claim(record, makePaneKey(tab.parentTabId, tab.leafId)) if (!resumeClaim) { - return {} + return vetoed } - const resume = await this.preparePaneOwnedResume(worktreeId, record).catch(() => null) + const resume = await this.preparePaneOwnedResume(worktreeId, record, !stored).catch(() => null) if (!resume) { resumeClaim.settleFailure(false) - return {} + return vetoed } return { ...resume, resumeClaim } } private async preparePaneOwnedResume( worktreeId: string, - record: SleepingAgentSessionRecord + record: SleepingAgentSessionRecord, + fromHookRows: boolean ): Promise { if ( (await this.isProviderSessionLiveInAnotherPane(record, record.paneKey)) || - this.hookStoreNamesAnotherSessionForPane(record) + this.hookStoreNamesAnotherSessionForPane(record, fromHookRows) ) { return null } @@ -173,10 +190,15 @@ export abstract class OrcaRuntimeWithAgentSessionResumeOwnership extends OrcaRun } /** The pane moved on to another conversation after the record was saved; the hook store is authoritative. */ - private hookStoreNamesAnotherSessionForPane(record: SleepingAgentSessionRecord): boolean { + private hookStoreNamesAnotherSessionForPane( + record: SleepingAgentSessionRecord, + fromHookRows: boolean + ): boolean { return (this.getAgentProviderSessionRowsForPaneFn?.(record.paneKey) ?? []).some( (row) => row.providerSession !== undefined && + // Judge a hook-read record by the rows it was read from. + (!fromHookRows || isPaneLocalHookRow(row, record.worktreeId)) && !agentProviderSessionsEqual(record.agent, row.providerSession, record.providerSession) ) } diff --git a/src/main/runtime/session-lifecycle/projection/hook-row-pane-resume-record.ts b/src/main/runtime/session-lifecycle/projection/hook-row-pane-resume-record.ts new file mode 100644 index 00000000000..43514dc3106 --- /dev/null +++ b/src/main/runtime/session-lifecycle/projection/hook-row-pane-resume-record.ts @@ -0,0 +1,52 @@ +import { + liveResumeRecord, + statusRetainsResumableIdentity, + type ResumeRecordTab +} from '../../../../shared/agent-resume-record-from-status' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-ipc-payload' +import { isLocalHookConnectionId } from '../../../../shared/wsl-hook-relay-contract' +import { resumeStatusFromHookRow } from '../../terminal/stop/hook-status-resume-records' + +/** A row this host's own pane wrote for this workspace; a relayed SSH row names another host's transcript. */ +export function isPaneLocalHookRow(row: AgentStatusIpcPayload, worktreeId: string): boolean { + return ( + isLocalHookConnectionId(row.connectionId ?? null) && + (!row.worktreeId || row.worktreeId === worktreeId) + ) +} + +/** + * The resume record a pane's own hook rows imply when no record was saved: a host whose daemon + * restarted under it (only layout-owner builds record hook status), or an agent typed into a + * shell. The execution host's hook store owns agent status, so it names the conversation. + */ +export function hookRowPaneResumeRecord(args: { + rows: readonly AgentStatusIpcPayload[] + worktreeId: string + tab: ResumeRecordTab | undefined +}): SleepingAgentSessionRecord | null { + const { worktreeId, tab } = args + const row = args.rows + .filter((candidate) => candidate.providerSession && isPaneLocalHookRow(candidate, worktreeId)) + .reduce( + (latest, candidate) => + !latest || candidate.receivedAt > latest.receivedAt ? candidate : latest, + null + ) + if (!row || !tab) { + return null + } + // An exited agent leaves only its resume identity, which is what a restore resumes. + const status = resumeStatusFromHookRow({ ...row, providerSessionOnly: false }, undefined) + if (!status?.providerSession) { + return null + } + return liveResumeRecord({ + entry: status, + retainsResumableIdentity: statusRetainsResumableIdentity(status, status.providerSession), + worktreeIdOf: () => worktreeId, + tabOf: () => tab, + capturedAt: status.updatedAt + }) +}