mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user