mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(native-chat): revalidate restored and retried owners
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+11
@@ -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
-1
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user