diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts index 66165139958..b881d55e771 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts @@ -125,3 +125,14 @@ export function structuredHandoffRetryIsAdmissible( status.error?.recoverableOwner !== 'none' ) } + +export function structuredHandoffRetryResumesStoppedOwner( + record: AgentSessionRecord, + params: AgentSessionHandoffRequest +): boolean { + return ( + record.lease.claimStatus === 'released' && + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.handoffOperationId === params.envelope.clientOperationId + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts index 9e5870289e8..f7bf717dea3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts @@ -41,6 +41,17 @@ export function createStructuredHandoffFlowContext(input: { } } +export function requireStructuredHandoffRecord( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string +): AgentSessionRecord { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return record +} + export async function stopStructuredNativeTurn( deps: StructuredAgentSessionHandoffDeps, sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts index 79b50c7e9b8..8925392e117 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts @@ -2,6 +2,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredHandoffRetryResumesStoppedOwner } from './structured-agent-session-handoff-admission' import { structuredSessionHasPendingPrompt } from './structured-agent-session-handoff-status' export function assertScheduledStructuredHandoffIsAdmissible(input: { @@ -14,7 +15,7 @@ export function assertScheduledStructuredHandoffIsAdmissible(input: { tuiStatus: 'idle' | 'busy' }): void { const { params, record } = input - if (params.action === 'retry') { + if (params.action === 'retry' && structuredHandoffRetryResumesStoppedOwner(record, params)) { return } const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' 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 4f32ef1279e..56f7a39c1ed 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 @@ -337,7 +337,6 @@ describe('structured session ownership handoff', () => { expect(new Set(messages.map((item) => item.itemId)).size).toBe(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', - claimStatus: 'live', handoffStage: null }) expect(prepareTuiHistoryCatchup).toHaveBeenCalledWith(SESSION, forwardRecord.lease.runtimeFence) @@ -707,27 +706,28 @@ describe('structured session ownership handoff', () => { owner: 'tui', error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' } }) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'tui', - claimStatus: 'live', - handoffStage: null, - handoffOperationId: null - }) }) - it('recovers native ownership when TUI launch or proof fails', async () => { + it('recovers native ownership and refuses retry over a new native turn', async () => { launchTui.mockRejectedValueOnce(new Error('resume proof failed')) - expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) - await waitForPhase('failed') - expect(coordinator.status(SESSION)).toMatchObject({ - owner: 'native', - error: { recoverableOwner: 'native' } + const retryOperation = operationId() + expect(await submit(request('to-tui', 'now', { operationId: retryOperation }))).toMatchObject({ + ok: true }) + await waitForPhase('failed') expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', claimStatus: 'live', handoffStage: null }) + await appendStatus('running') + const retry = await submit( + request('to-tui', 'now', { action: 'retry', operationId: retryOperation }) + ) + expect(retry).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict' } + }) }) it('stops a launched but uncommitted TUI before recovering native', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts index 2f71010956f..2c8e54b18d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -11,9 +11,13 @@ import { admitStructuredHandoffRequest, refuseAdmittedStructuredHandoff, replayedStructuredHandoffRefusal, - structuredHandoffRetryIsAdmissible + structuredHandoffRetryIsAdmissible, + structuredHandoffRetryResumesStoppedOwner } from './structured-agent-session-handoff-admission' -import { createStructuredHandoffFlowContext } from './structured-agent-session-handoff-flow-context' +import { + createStructuredHandoffFlowContext, + requireStructuredHandoffRecord +} from './structured-agent-session-handoff-flow-context' import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' import { beginStructuredManualRecovery, @@ -58,10 +62,8 @@ export class StructuredAgentSessionHandoffCoordinator { }) } - status(sessionId: string): AgentSessionHandoffStatus { - const current = this.statuses.get(sessionId) - return current ?? idleStructuredHandoffStatus(this.requireRecord(sessionId)) - } + status = (sessionId: string): AgentSessionHandoffStatus => + this.statuses.get(sessionId) ?? idleStructuredHandoffStatus(this.requireRecord(sessionId)) drain = (): Promise => this.flowRunner.drain() @@ -171,8 +173,10 @@ export class StructuredAgentSessionHandoffCoordinator { 'This handoff is no longer retryable.' ) } - this.begin(callerKey, params, null, fingerprint) - return this.success(record.sessionId, false) + if (structuredHandoffRetryResumesStoppedOwner(record, params)) { + this.begin(callerKey, params, null, fingerprint) + return this.success(record.sessionId, false) + } } const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') { @@ -304,10 +308,6 @@ export class StructuredAgentSessionHandoffCoordinator { } private requireRecord(sessionId: string): AgentSessionRecord { - const record = this.deps.store.getRecord(sessionId) - if (!record) { - throw new Error('agent_session_identity_required') - } - return record + return requireStructuredHandoffRecord(this.deps, sessionId) } } diff --git a/src/main/runtime/agent-session-pty-write-gate.test.ts b/src/main/runtime/agent-session-pty-write-gate.test.ts index 60d0a947810..7d3ef8e6576 100644 --- a/src/main/runtime/agent-session-pty-write-gate.test.ts +++ b/src/main/runtime/agent-session-pty-write-gate.test.ts @@ -159,6 +159,26 @@ describe('admission through the store', () => { ) }) + it('admits proof input for the exact proven live owner during restore', () => { + publish( + agentSessionLeaseFixture({ + ownerProcess: { + hostId: 'local', + pid: 4200, + processStartTimeMs: 10, + spawnToken: 'live-proof-token' + }, + reservedSpawnToken: 'live-proof-token' + }) + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'live-proof-token' })).toBe( + true + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( + false + ) + }) + it('refuses when the record vanished from the store', () => { const admission = gate.admit(PTY_ID) expect(admission.admitted).toBe(false) diff --git a/src/main/runtime/agent-session-pty-write-gate.ts b/src/main/runtime/agent-session-pty-write-gate.ts index 06dd237a1c4..f252e6f938b 100644 --- a/src/main/runtime/agent-session-pty-write-gate.ts +++ b/src/main/runtime/agent-session-pty-write-gate.ts @@ -73,14 +73,21 @@ export class AgentSessionPtyWriteGate { admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean { const binding = this.binding(ptyId) const lease = binding?.record?.lease + const provingReservation = + lease?.claimStatus === 'reserved' && + lease.handoffStage === 'new-owner-proving' && + lease.reservedSpawnToken === authority.spawnToken && + (lease.ownerProcess === null || lease.ownerProcess.spawnToken === authority.spawnToken) + const reprovingLiveOwner = + lease?.claimStatus === 'live' && + lease.handoffStage === null && + lease.ownerProcess?.spawnToken === authority.spawnToken && + lease.provenHandleLinkId !== null return Boolean( binding?.sessionId === authority.sessionId && binding.record?.sessionId === authority.sessionId && lease?.runtimeKind === 'tui' && - lease.claimStatus === 'reserved' && - lease.handoffStage === 'new-owner-proving' && - lease.reservedSpawnToken === authority.spawnToken && - (lease.ownerProcess === null || lease.ownerProcess.spawnToken === authority.spawnToken) && + (provingReservation || reprovingLiveOwner) && !lease.unreconciled ) } diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index 0c0692ee74b..6537a7f80e9 100644 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { OrcaRuntimeService } from './orca-runtime' const { @@ -157,6 +158,8 @@ describe('structured TUI launch tab binding', () => { }) expect(resolvePinnedCodexRolloutProof).toHaveBeenCalledWith(namespace.providerRoot, 'thread-1') expect(writeAgentSessionProof).not.toHaveBeenCalled() + expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1') + agentSessionPtyWriteGate.unbindPty('pty-cold-owner') }) it('proves the published launch tab before returning its revealed renderer binding', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 86212bf4c19..e447123215f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9310,6 +9310,7 @@ export class OrcaRuntimeService { if (!candidate?.tabId || !candidate.paneKey || !handle) { throw new Error('The owning agent terminal could not be recovered.') } + agentSessionPtyWriteGate.bindPty(candidate.ptyId, record.sessionId) const proof = durableOwner ? await this.resolveRecoveredStructuredTuiTranscript({ handle,