diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts index 74cf24f913b..d99a36699bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -10,6 +10,7 @@ import type { StructuredAgentSessionHandoffFlowContext, StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' export async function handoffStructuredSessionToTui( context: StructuredAgentSessionHandoffFlowContext, @@ -52,19 +53,32 @@ export async function handoffStructuredSessionToTui( }) context.publishStage(record, 'to-tui') let owner: StructuredTuiOwner | null = null + let processIdentityCommitted = false try { await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) owner = await deps.transport!.launchTui({ record, fence: record.lease.runtimeFence, - spawnToken - }) - await deps.store.commitProcessIdentity({ - sessionId, - fence: record.lease.runtimeFence, - process: owner.process, - now: deps.now() + spawnToken, + onSpawned: async (spawnedOwner) => { + owner = spawnedOwner + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: spawnedOwner.process, + now: deps.now() + }) + processIdentityCommitted = true + } }) + if (!processIdentityCommitted) { + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: owner.process, + now: deps.now() + }) + } record = await deps.store.proveOwner({ sessionId, fence: record.lease.runtimeFence, @@ -73,14 +87,20 @@ export async function handoffStructuredSessionToTui( }) } catch (error) { deps.stopTuiHistoryCatchup?.(sessionId) + if (!owner && error instanceof StructuredTuiLaunchCleanupError) { + await markManualRecovery(context, sessionId, operationId) + throw error + } if (owner) { if (!deps.transport?.stopFailedTuiLaunch) { + context.retainOwner(sessionId, owner) await markManualRecovery(context, sessionId, operationId) throw error } try { await deps.transport.stopFailedTuiLaunch(owner) } catch (stopError) { + context.retainOwner(sessionId, owner) await markManualRecovery(context, sessionId, operationId) throw new AggregateError( [error, stopError], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts index 0d663dc3e63..c52013c5b2d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -14,12 +14,23 @@ export type StructuredTuiOwner = { transcriptPath?: string } +export class StructuredTuiLaunchCleanupError extends Error { + constructor( + launchError: unknown, + readonly cleanupError: unknown + ) { + super('The failed terminal launch could not be proven stopped.', { cause: launchError }) + this.name = 'StructuredTuiLaunchCleanupError' + } +} + export type StructuredAgentSessionHandoffTransport = { hostLabel: string launchTui(input: { record: AgentSessionRecord fence: number spawnToken: string + onSpawned?: (owner: StructuredTuiOwner) => Promise }): Promise reproveTuiOwner(input: { record: AgentSessionRecord diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts index 187b12f4a35..22edef8848c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -785,6 +785,7 @@ describe('structured session ownership handoff', () => { stage: 'manual-recovery', error: { recoverableOwner: 'none' } }) + expect(acquireNativeCalls).toBe(0) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery') await coordinator.restore(SESSION) expect(coordinator.status(SESSION)).toMatchObject({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c8c27ee8ec9..f934d6ed406 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -75,9 +75,10 @@ import { import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import type { - StructuredAgentSessionHandoffTransport, - StructuredTuiOwner +import { + StructuredTuiLaunchCleanupError, + type StructuredAgentSessionHandoffTransport, + type StructuredTuiOwner } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { @@ -9121,7 +9122,7 @@ export class OrcaRuntimeService { private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { return { hostLabel: hostname(), - launchTui: async ({ record, fence, spawnToken }) => { + launchTui: async ({ record, fence, spawnToken, onSpawned }) => { const head = record.providerHandleChain.at(-1) if (head?.handle.provider !== 'codex') { throw new Error('agent_session_identity_required') @@ -9142,23 +9143,10 @@ export class OrcaRuntimeService { if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { throw new Error('The resumed terminal did not publish a process identity.') } - await this.waitForTerminal(terminal.handle, { - condition: 'tui-idle', - timeoutMs: 30_000 - }) - const proof = await this.waitForStructuredTuiProof({ - handle: terminal.handle, - paneKey: terminal.paneKey, - threadId: head.handle.threadId, - spawnToken, - codexHome: record.accountHome.path, - sessionId: record.sessionId - }) - const revealed = await this.focusTerminal(terminal.handle) - return this.refreshStructuredTuiOwnerBinding({ + const spawnedOwner = this.refreshStructuredTuiOwnerBinding({ terminal: { handle: terminal.handle, - tabId: revealed.tabId, + tabId: terminal.tabId, paneKey: terminal.paneKey, ptyId: terminal.ptyId }, @@ -9173,15 +9161,43 @@ export class OrcaRuntimeService { resumed: true, fence, observedAt: Date.now() - }), + }) + }) + await onSpawned?.(spawnedOwner) + await this.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 30_000 + }) + const proof = await this.waitForStructuredTuiProof({ + handle: terminal.handle, + paneKey: terminal.paneKey, + threadId: head.handle.threadId, + spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + const revealed = await this.focusTerminal(terminal.handle) + return this.refreshStructuredTuiOwnerBinding({ + ...spawnedOwner, + terminal: { + handle: terminal.handle, + tabId: revealed.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: spawnedOwner.process, ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}) }) } catch (error) { - await this.closeTerminal(terminal.handle) - await this.waitForTerminal(terminal.handle, { - condition: 'exit', - timeoutMs: 15_000 - }) + try { + await this.closeTerminal(terminal.handle) + await this.waitForTerminal(terminal.handle, { + condition: 'exit', + timeoutMs: 15_000 + }) + } catch (cleanupError) { + throw new StructuredTuiLaunchCleanupError(error, cleanupError) + } throw error } },