fix(native-chat): revalidate restored and retried owners

This commit is contained in:
Brennan Benson
2026-08-12 18:39:16 -07:00
parent 35fb039187
commit a03beee134
9 changed files with 85 additions and 31 deletions
@@ -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
)
}
@@ -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,
@@ -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'
@@ -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 () => {
@@ -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<void> => 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)
}
}
@@ -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)
@@ -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
)
}
@@ -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 () => {
+1
View File
@@ -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,