fix(runtime): a restored host pane resumes its agent from the hook store, never replays the launch (#34054)

* fix(runtime): a restored host pane resumes its agent from the hook store, never replays the launch

When a WSL host's daemon restarted, the host re-created each pane with its
original launch command (a fresh Codex conversation), or a plain shell for an
agent typed into one: orcad has no resume record, because only layout-owner
builds record hook status. A pane with no record now resumes the conversation
its own hook rows name (local rows only, this workspace's), through the same
pane-owned resume path a recorded session takes.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(runtime): hook-store resume vetoes: other worktree, two sessions, shared or live elsewhere, structured host

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(runtime): a vetoed hook-store resume launches the tab's agent fresh, as before

A record read from the hook store is no promise to keep: when it is vetoed
(two sessions, shared or live in another pane, no resume command) the tab's
agent now launches fresh as main did, not a plain shell. Its two-session veto
counts only the rows it was read from (local, this workspace), so a relayed
SSH row no longer vetoes a local resume.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-26 15:31:05 -07:00
committed by GitHub
co-authored by Claude
parent 86a75e99c6
commit 8a001a93c4
3 changed files with 283 additions and 8 deletions
@@ -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> = {}): 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<WorkspaceSessionState>
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<typeof OrcaRuntimeService>): Promise<void> {
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)
})
})
@@ -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<PaneResumeStartup | Record<string, never> | 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<MobileTerminalResumeCommand | null> {
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)
)
}
@@ -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<AgentStatusIpcPayload | null>(
(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
})
}