fix(native-chat): harden shared handoff recovery

This commit is contained in:
Brennan Benson
2026-08-12 18:36:05 -07:00
parent 53a3a9442b
commit 2d3b082ab2
24 changed files with 490 additions and 119 deletions
+10 -24
View File
@@ -229,7 +229,7 @@ import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay'
import { MobileStructuredAgentSessionView } from '../../../../src/session/MobileStructuredAgentSessionView'
import { MobileStructuredSessionCreateError } from '../../../../src/session/MobileStructuredSessionCreateError'
import { sendMobileStructuredTuiMessage } from '../../../../src/session/mobile-structured-tui-send'
import * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send'
import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry'
import { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create'
import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet'
@@ -4660,33 +4660,19 @@ export default function SessionScreen() {
}
return accepted
}}
onTuiSend={async (text, restored) => {
const handoff = structuredSessionEntry.session.handoff
const terminal =
handoff?.owner === 'tui' && handoff.phase === 'idle'
? handoff.terminal?.handle
: null
if (!client || connState !== 'connected' || !terminal) {
showToast('Message not sent (disconnected)', 1800)
return false
}
const outcome = await sendMobileStructuredTuiMessage({
onTuiSend={(text, restored) =>
mobileStructuredTuiSend.sendMobileStructuredTuiComposerMessage({
client,
terminal,
connected: connState === 'connected',
agent: activeStructuredTab.agent,
handoff: structuredSessionEntry.session.handoff,
deviceToken: deviceTokenRef.current,
text,
attachments: [...restored, ...structuredSessionEntry.attachments.attachments]
attachments: [...restored, ...structuredSessionEntry.attachments.attachments],
onAccepted: structuredSessionEntry.attachments.clear,
onToast: showToast
})
if (outcome === 'rejected') {
showToast('Message not sent', 1800)
return false
}
structuredSessionEntry.attachments.clear()
if (outcome === 'unknown') {
showToast('Delivery unconfirmed — check chat before retrying', 2400)
}
return true
}}
}
onTakeQueuedForEdit={structuredSessionEntry.writes.takeQueuedForEdit}
onRetry={structuredSessionEntry.writes.retry}
onRespondToPrompt={structuredSessionEntry.writes.respondToPrompt}
@@ -73,7 +73,7 @@ type Props = {
onRequestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry'
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
) => Promise<boolean>
}
@@ -350,6 +350,10 @@ function MobileStructuredHandoffBanner(props: {
<Pressable onPress={() => void props.onRequest('to-native', 'after-turn')}>
<Text style={styles.handoffButtonText}>Return to chat</Text>
</Pressable>
) : status.phase === 'failed' && status.direction && status.error?.canRetryProof ? (
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'recover')}>
<Text style={styles.handoffButtonText}>Retry proof</Text>
</Pressable>
) : status.phase === 'failed' &&
status.direction &&
status.error?.recoverableOwner !== 'none' ? (
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock'
import { sendMobileStructuredTuiMessage } from './mobile-structured-tui-send'
import {
sendMobileStructuredTuiComposerMessage,
sendMobileStructuredTuiMessage
} from './mobile-structured-tui-send'
function acceptingClient(): RpcClient {
return {
@@ -29,6 +32,7 @@ describe('sendMobileStructuredTuiMessage', () => {
sendMobileStructuredTuiMessage({
client,
terminal: 'term-tui',
agent: 'codex',
deviceToken: 'mobile-1',
text: 'hello',
attachments: []
@@ -54,6 +58,7 @@ describe('sendMobileStructuredTuiMessage', () => {
const sending = sendMobileStructuredTuiMessage({
client,
terminal: 'term-tui',
agent: 'codex',
deviceToken: null,
text: '/model',
attachments: []
@@ -75,4 +80,52 @@ describe('sendMobileStructuredTuiMessage', () => {
)
).toBe(true)
})
it('types Claude-only slash commands through the provider command path', async () => {
vi.useFakeTimers()
const client = acceptingClient()
const sending = sendMobileStructuredTuiMessage({
client,
terminal: 'term-claude',
agent: 'claude',
deviceToken: null,
text: '/clear',
attachments: []
})
await vi.runAllTimersAsync()
await expect(sending).resolves.toBe('accepted')
expect(
vi
.mocked(client.sendRequest)
.mock.calls.map(([, params]) => (params as { text: string }).text)
).toEqual(['\x15', '/', 'c', 'l', 'e', 'a', 'r', '\r'])
})
it('settles the composer only after its proven TUI owner accepts the send', async () => {
const onAccepted = vi.fn()
await expect(
sendMobileStructuredTuiComposerMessage({
client: acceptingClient(),
connected: true,
agent: 'claude',
handoff: {
owner: 'tui',
direction: null,
phase: 'idle',
stage: null,
operationId: null,
terminal: { handle: 'term-claude', tabId: 'tab-claude', paneKey: 'pane-claude' }
},
deviceToken: null,
text: 'hello',
attachments: [],
onAccepted,
onToast: vi.fn()
})
).resolves.toBe(true)
expect(onAccepted).toHaveBeenCalledOnce()
})
})
@@ -1,4 +1,5 @@
import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands'
import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import {
@@ -25,6 +26,7 @@ import {
type MobileStructuredTuiSendArgs = {
client: RpcClient
terminal: string
agent: 'claude' | 'codex'
deviceToken: string | null
text: string
attachments: readonly PendingNativeChatImage[]
@@ -36,6 +38,14 @@ const wait = (ms: number): Promise<void> =>
setTimeout(resolve, ms)
})
export function getMobileStructuredTuiTerminal(
handoff: AgentSessionHandoffStatus | null
): string | null {
return handoff?.owner === 'tui' && handoff.phase === 'idle'
? (handoff.terminal?.handle ?? null)
: null
}
/** Sends the structured transcript composer through its proven TUI owner. */
export async function sendMobileStructuredTuiMessage(
args: MobileStructuredTuiSendArgs
@@ -75,7 +85,7 @@ export async function sendMobileStructuredTuiMessage(
}
const textDeadline =
args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline
const classification = classifyMobileNativeChatSend('codex', args.text)
const classification = classifyMobileNativeChatSend(args.agent, args.text)
const outcome =
classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0
? await typeMobileNativeChatCommandWithOutcome({
@@ -106,3 +116,38 @@ export async function sendMobileStructuredTuiMessage(
releaseMobileNativeChatTerminalWrite(args.terminal)
}
}
export async function sendMobileStructuredTuiComposerMessage(args: {
client: RpcClient | null
connected: boolean
agent: 'claude' | 'codex'
handoff: AgentSessionHandoffStatus | null
deviceToken: string | null
text: string
attachments: readonly PendingNativeChatImage[]
onAccepted: () => void
onToast: (message: string, durationMs: number) => void
}): Promise<boolean> {
const terminal = getMobileStructuredTuiTerminal(args.handoff)
if (!args.client || !args.connected || !terminal) {
args.onToast('Message not sent (disconnected)', 1800)
return false
}
const outcome = await sendMobileStructuredTuiMessage({
client: args.client,
terminal,
agent: args.agent,
deviceToken: args.deviceToken,
text: args.text,
attachments: args.attachments
})
if (outcome === 'rejected') {
args.onToast('Message not sent', 1800)
return false
}
args.onAccepted()
if (outcome === 'unknown') {
args.onToast('Delivery unconfirmed — check chat before retrying', 2400)
}
return true
}
@@ -32,7 +32,7 @@ export type MobileStructuredSessionMutations = {
requestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry'
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
) => Promise<boolean>
}
@@ -146,7 +146,7 @@ export function useMobileStructuredSessionMutations(args: {
requestHandoff: async (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action: 'start' | 'cancel-queued' | 'retry' = 'start'
action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start'
) =>
Boolean(
await mutate(
@@ -15,6 +15,7 @@ import type { AgentSessionHandleProvider } from '../../../shared/agent-session-p
import type {
AgentSessionAccountHome,
AgentSessionExecutionLocation,
AgentSessionLaunchEnv,
AgentSessionOwnerRuntimeKind,
AgentSessionRecord
} from '../../../shared/agent-session-record'
@@ -61,6 +62,7 @@ export type AgentSessionAttachAuthority = {
claimKeyId: string
handoffOperationId: string | null
probe: AgentSessionOwnerProbe
launchEnv?: AgentSessionLaunchEnv
}
/** The fields that define WHICH session this call would attach to. Deliberately
@@ -177,6 +179,7 @@ export function reserveRequestFor(input: {
location: params.location,
provider: params.provider,
accountHome: params.accountHome,
...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}),
runtimeKind: params.runtimeKind,
expectedFence: params.envelope.expectedRuntimeFence,
spawnToken: authority.spawnToken,
@@ -24,6 +24,11 @@ export class StructuredAgentSessionHandoffFlowRunner {
await Promise.allSettled(this.active)
}
track(task: Promise<void>): void {
this.active.add(task)
void task.finally(() => this.active.delete(task))
}
begin(input: {
callerKey: string
params: AgentSessionHandoffRequest
@@ -55,8 +60,7 @@ export class StructuredAgentSessionHandoffFlowRunner {
this.input.fail(params, error)
})
.finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId))
this.active.add(flow)
void flow.finally(() => this.active.delete(flow))
this.track(flow)
}
private run(
@@ -20,7 +20,7 @@ export class StructuredAgentSessionHandoffOperationGuard {
sessionId: string
operationId: string
fingerprint: string
action: 'start' | 'cancel-queued' | 'retry'
action: 'start' | 'cancel-queued' | 'retry' | 'recover'
status?: AgentSessionHandoffStatus
now: number
}): Promise<HandoffOperationDecision> {
@@ -1,3 +1,13 @@
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffStatus
} from '../../../shared/agent-session-wire'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import type {
StructuredAgentSessionHandoffDeps,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
export class StructuredAgentSessionHandoffQueue {
private readonly controllers = new Map<string, AbortController>()
@@ -42,3 +52,37 @@ export class StructuredAgentSessionHandoffQueue {
return false
}
}
export function enqueueStructuredHandoffAfterTurn(input: {
deps: StructuredAgentSessionHandoffDeps
queue: StructuredAgentSessionHandoffQueue
params: AgentSessionHandoffRequest
tuiOwner: StructuredTuiOwner | undefined
setStatus: (status: AgentSessionHandoffStatus) => void
begin: (params: AgentSessionHandoffRequest, tuiAlreadyExited: boolean) => void
}): void {
const { deps, params, queue, tuiOwner } = input
const sessionId = params.envelope.sessionId
let tuiReadiness: 'idle' | 'exited' | null = null
input.setStatus({
owner: params.direction === 'to-tui' ? 'native' : 'tui',
direction: params.direction,
phase: 'queued',
stage: null,
operationId: params.envelope.clientOperationId,
hostLabel: deps.transport?.hostLabel
})
queue.enqueue(
sessionId,
async (signal) => {
if (params.direction === 'to-tui') {
return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items)
}
tuiReadiness = tuiOwner
? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null)
: null
return tuiReadiness !== null
},
() => input.begin({ ...params, mode: 'now' }, tuiReadiness === 'exited')
)
}
@@ -25,15 +25,21 @@ export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSe
return persistedFailedStructuredHandoffStatus(record)
}
if (record.lease.handoffStage === 'manual-recovery') {
const canRetryProof =
record.lease.runtimeKind === 'tui' &&
record.lease.ownerProcess !== null &&
((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) ||
(record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null))
return {
owner: 'none',
direction: null,
direction: record.lease.runtimeKind === 'tui' ? 'to-tui' : 'to-native',
phase: 'failed',
stage: 'manual-recovery',
operationId: record.lease.handoffOperationId,
error: {
message: "Couldn't verify which runtime owns this session — manual recovery is required",
recoverableOwner: 'none'
recoverableOwner: 'none',
...(canRetryProof ? { canRetryProof: true } : {})
}
}
}
@@ -128,6 +134,12 @@ export function failedStructuredHandoffStatus(
: params.direction === 'to-tui' && record.lease.runtimeKind === 'native'
? 'native'
: 'none'
const canRetryProof =
record.lease.handoffStage === 'manual-recovery' &&
record.lease.runtimeKind === 'tui' &&
record.lease.ownerProcess !== null &&
((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) ||
(record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null))
return {
owner: recoverableOwner,
direction: params.direction,
@@ -143,7 +155,8 @@ export function failedStructuredHandoffStatus(
? "Couldn't open the agent terminal — chat still owns this session"
: "Couldn't resume chat — the agent terminal still owns this session",
details: error instanceof Error ? error.message : String(error),
recoverableOwner
recoverableOwner,
...(canRetryProof ? { canRetryProof: true } : {})
}
}
}
@@ -774,27 +774,24 @@ describe('structured session ownership handoff', () => {
link: link(fence + 1, 'bad-proof')
}))
stopFailedTuiLaunch.mockRejectedValueOnce(new Error('exit unproved'))
const failedOperation = operationId()
expect(await submit(request('to-tui', 'now', { operationId: failedOperation }))).toMatchObject({
ok: true
})
expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true })
await waitForPhase('failed')
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'none',
stage: 'manual-recovery',
error: { recoverableOwner: 'none' }
error: { recoverableOwner: 'none', canRetryProof: true }
})
expect(await submit(request('to-tui', 'now', { action: 'recover' }))).toMatchObject({
ok: true
})
await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui'))
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeKind: 'tui',
claimStatus: 'live',
handoffStage: null,
handoffOperationId: null
})
expect(acquireNativeCalls).toBe(0)
expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery')
await coordinator.restore(SESSION)
expect(coordinator.status(SESSION)).toMatchObject({
owner: 'none',
stage: 'manual-recovery'
})
expect(
await submit(request('to-tui', 'now', { action: 'retry', operationId: failedOperation }))
).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } })
})
it('retains the stopped TUI session for retry when native resume fails', async () => {
@@ -16,7 +16,14 @@ import {
} from './structured-agent-session-handoff-admission'
import { createStructuredHandoffFlowContext } from './structured-agent-session-handoff-flow-context'
import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner'
import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue'
import {
beginStructuredManualRecovery,
structuredManualRecoveryIsAdmissible
} from './structured-agent-session-manual-recovery'
import {
enqueueStructuredHandoffAfterTurn,
StructuredAgentSessionHandoffQueue
} from './structured-agent-session-handoff-queue'
import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart'
import {
@@ -126,6 +133,28 @@ export class StructuredAgentSessionHandoffCoordinator {
'Agent TUI handoff is unavailable on this host.'
)
}
if (action === 'recover') {
if (!structuredManualRecoveryIsAdmissible(record, currentStatus)) {
return this.refuseAdmitted(
callerKey,
params,
'agent_session_operation_conflict',
'This session no longer has a recoverable TUI proof.'
)
}
const recovery = beginStructuredManualRecovery({
deps: this.deps,
operationGuard: this.operationGuard,
callerKey,
params,
fingerprint,
requireRecord: (sessionId) => this.requireRecord(sessionId),
restore: (sessionId) => this.restore(sessionId),
setStatus: (sessionId, status) => this.setStatus(sessionId, status)
})
this.flowRunner.track(recovery)
return this.success(record.sessionId, false)
}
if (action === 'retry') {
if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) {
return this.refuseAdmitted(
@@ -172,7 +201,14 @@ export class StructuredAgentSessionHandoffCoordinator {
)
}
if (busy && params.mode === 'after-turn') {
this.queueAfterTurn(callerKey, params, fingerprint)
enqueueStructuredHandoffAfterTurn({
deps: this.deps,
queue: this.queue,
params,
tuiOwner,
setStatus: (status) => this.setStatus(record.sessionId, status),
begin: (next, exited) => this.begin(callerKey, next, null, fingerprint, exited)
})
return this.success(record.sessionId, false)
}
if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') {
@@ -201,45 +237,8 @@ export class StructuredAgentSessionHandoffCoordinator {
})
}
private success(sessionId: string, replayed: boolean) {
return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId))
}
private queueAfterTurn(
callerKey: string,
params: AgentSessionHandoffRequest,
fingerprint: string
): void {
const sessionId = params.envelope.sessionId
let tuiReadiness: 'idle' | 'exited' | null = null
this.setStatus(sessionId, {
owner: params.direction === 'to-tui' ? 'native' : 'tui',
direction: params.direction,
phase: 'queued',
stage: null,
operationId: params.envelope.clientOperationId,
hostLabel: this.deps.transport?.hostLabel
})
const tuiOwner = this.tuiOwners.get(sessionId)
this.queue.enqueue(
sessionId,
async (signal) => {
if (params.direction === 'to-tui') {
return !activeStructuredAgentSessionTurnId(
this.deps.session(sessionId).journal.snapshot().items
)
}
tuiReadiness = tuiOwner
? ((await this.deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null)
: null
return tuiReadiness !== null
},
() => {
const next = { ...params, mode: 'now' as const }
this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited')
}
)
}
private success = (sessionId: string, replayed: boolean) =>
structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId))
private begin(
callerKey: string,
@@ -0,0 +1,30 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { structuredTuiTranscriptImportOptions } from './structured-agent-session-host-handoff'
function importRecord(provider: 'claude' | 'codex', accountHome: string): AgentSessionRecord {
return {
provider,
accountHome: {
variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: accountHome
}
} as AgentSessionRecord
}
describe('structured TUI transcript import roots', () => {
it('uses the managed Claude account home when no live transcript path remains', () => {
expect(structuredTuiTranscriptImportOptions(importRecord('claude', '/managed/claude'))).toEqual(
{
claudeProjectsDir: join('/managed/claude', 'projects')
}
)
})
it('uses the managed Codex account home when no live transcript path remains', () => {
expect(structuredTuiTranscriptImportOptions(importRecord('codex', '/managed/codex'))).toEqual({
codexSessionsDirs: [join('/managed/codex', 'sessions')]
})
})
})
@@ -1,5 +1,6 @@
import { join } from 'node:path'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import'
import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
import { journalIdentityFor } from './structured-agent-session-attach'
import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart'
@@ -94,14 +95,13 @@ async function importTuiHistory(
if (!record || head?.handle.provider !== 'codex') {
throw new Error('agent_session_identity_required')
}
const options = structuredTuiTranscriptImportOptions(record, input.transcriptPath)
const imported = await importLegacyTranscriptIntoJournal({
journal: session.journal,
agent: 'codex',
sessionId: head.handle.threadId,
fence: input.fence,
options: input.transcriptPath
? { filePath: input.transcriptPath }
: { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] }
options
})
if (!imported.ok) {
throw new Error(imported.error)
@@ -109,6 +109,18 @@ async function importTuiHistory(
host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence)
}
export function structuredTuiTranscriptImportOptions(
record: AgentSessionRecord,
transcriptPath?: string
): LegacyImportOptions {
if (transcriptPath) {
return { filePath: transcriptPath }
}
return record.provider === 'claude'
? { claudeProjectsDir: join(record.accountHome.path, 'projects') }
: { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] }
}
async function acquireNativeHandoffOwner(
deps: StructuredAgentSessionHostDeps,
host: HostHandoffAccess,
@@ -1,5 +1,10 @@
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types'
export type StructuredAgentSessionCaller = { callerKey: string }
@@ -8,3 +13,18 @@ export type StructuredAgentSessionHostSession = {
params: AgentSessionAttachParams
fence: number
}
export type StructuredAgentSessionHostDeps = {
store: AgentSessionRecordStore
adapter: StructuredAgentSessionAdapter
journalRoot: string
claimKeyId: string
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
mintSpawnToken?: () => string
resolveLaunchEnv?: (
provider: AgentSessionRecord['provider']
) => Record<string, string> | undefined
now?: () => number
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
}
@@ -3,11 +3,7 @@
import { randomUUID } from 'node:crypto'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type {
AgentSessionExecutionLocation,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
import type {
AgentSessionAttachResult,
AgentSessionCancelResult,
@@ -24,9 +20,7 @@ import type {
AgentSessionSendResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { readAgentSessionHistory } from './agent-session-history-page'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { performAttach } from './structured-agent-session-attach-flow'
import {
@@ -47,7 +41,6 @@ import {
type AgentSessionSubscribeInput
} from './structured-agent-session-subscribers'
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types'
import {
createStructuredAgentSessionHostHandoff,
refreshRecoverableStructuredHandoffStatus,
@@ -58,20 +51,10 @@ import { listStructuredAgentSessionTabs } from './structured-agent-session-host-
import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer'
import type {
StructuredAgentSessionCaller,
StructuredAgentSessionHostDeps,
StructuredAgentSessionHostSession
} from './structured-agent-session-host-types'
export type StructuredAgentSessionHostDeps = {
store: AgentSessionRecordStore
adapter: StructuredAgentSessionAdapter
journalRoot: string
claimKeyId: string
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
mintSpawnToken?: () => string
now?: () => number
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
}
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
export class StructuredAgentSessionHost {
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
@@ -169,7 +152,10 @@ export class StructuredAgentSessionHost {
spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(),
claimKeyId: this.deps.claimKeyId,
handoffOperationId: params.envelope.clientOperationId,
probe: await this.runtimeState.probeOwner(sessionId)
probe: await this.runtimeState.probeOwner(sessionId),
...(this.deps.resolveLaunchEnv
? { launchEnv: this.deps.resolveLaunchEnv(params.provider) }
: {})
},
callerKey: caller.callerKey,
params,
@@ -0,0 +1,101 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionHandoffRequest,
AgentSessionHandoffStatus
} from '../../../shared/agent-session-wire'
import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions'
import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard'
import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status'
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
export function structuredManualRecoveryIsAdmissible(
record: AgentSessionRecord,
status: AgentSessionHandoffStatus | undefined
): boolean {
return (
record.lease.handoffStage === 'manual-recovery' &&
record.lease.runtimeKind === 'tui' &&
record.lease.ownerProcess !== null &&
status?.error?.canRetryProof === true
)
}
export function beginStructuredManualRecovery(input: {
deps: StructuredAgentSessionHandoffDeps
operationGuard: StructuredAgentSessionHandoffOperationGuard
callerKey: string
params: AgentSessionHandoffRequest
fingerprint: string
requireRecord: (sessionId: string) => AgentSessionRecord
restore: (sessionId: string) => Promise<void>
setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void
}): Promise<void> {
const {
callerKey,
deps,
fingerprint,
operationGuard,
params,
requireRecord,
restore,
setStatus
} = input
const sessionId = params.envelope.sessionId
operationGuard.start(sessionId, {
callerKey,
operationId: params.envelope.clientOperationId,
fingerprint
})
setStatus(sessionId, {
owner: 'none',
direction: params.direction,
phase: 'switching',
stage: 'recovering',
operationId: params.envelope.clientOperationId,
hostLabel: deps.transport?.hostLabel
})
return deps
.schedule(sessionId, async () => {
let record = requireRecord(sessionId)
if (record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) {
record = await setStoredAgentSessionHandoffStage(deps.store, {
sessionId,
fence: record.lease.runtimeFence,
stage: 'new-owner-proving',
handoffOperationId: record.lease.handoffOperationId,
now: deps.now()
})
}
await restore(record.sessionId)
if (requireRecord(sessionId).lease.handoffStage === 'manual-recovery') {
throw new Error('The TUI owner proof is still unavailable.')
}
})
.then(() =>
deps.store.recordOperationOutcome({
callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'succeeded', sessionId }
})
)
.catch(async (error) => {
await deps.store.recordOperationOutcome({
callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'failed', code: 'agent_session_handoff_failed' }
})
const status = idleStructuredHandoffStatus(requireRecord(sessionId))
setStatus(sessionId, {
...status,
...(status.error
? {
error: {
...status.error,
details: error instanceof Error ? error.message : String(error)
}
}
: {})
})
})
.finally(() => operationGuard.finish(sessionId, params.envelope.clientOperationId))
}
@@ -18,6 +18,7 @@ import {
agentSessionExecutionLocationsEqual,
type AgentSessionAccountHome,
type AgentSessionExecutionLocation,
type AgentSessionLaunchEnv,
type AgentSessionRecord
} from '../../shared/agent-session-record'
import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle'
@@ -32,6 +33,7 @@ export type AgentSessionReserveRequest = {
location: AgentSessionExecutionLocation
provider: AgentSessionHandleProvider
accountHome: AgentSessionAccountHome
launchEnv?: AgentSessionLaunchEnv
runtimeKind: AgentSessionReservation['runtimeKind']
/** Null when the session does not exist yet; otherwise the fence the caller last observed. */
expectedFence: number | null
@@ -142,6 +144,7 @@ function createAgentSessionRecord(
provider: request.provider,
providerHandleChain: [],
accountHome: request.accountHome,
...(request.launchEnv ? { launchEnv: { ...request.launchEnv } } : {}),
createdAt: request.now,
updatedAt: request.now,
lease: {
+32
View File
@@ -9361,6 +9361,38 @@ export class OrcaRuntimeService {
}
}
private recoverClaudeStructuredTuiProof(input: {
candidate: RuntimePtyWorktreeRecord
sessionId: string
spawnToken: string
}): { transcriptPath: string } {
const paneKey = input.candidate.paneKey
if (!paneKey) {
throw new Error('The owning Claude terminal did not retain its pane identity.')
}
const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({
paneKey,
launchTokenHash: createHash('sha256').update(input.spawnToken).digest('hex'),
connectionId: input.candidate.connectionId,
terminalProvenance: 'restored'
})
if (attestation?.paneKey !== paneKey) {
throw new Error('The owning Claude terminal did not retain launch-token authority.')
}
const matches = (this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []).filter(
(row) =>
row.paneKey === paneKey &&
row.connectionId === input.candidate.connectionId &&
row.providerSession?.key === 'session_id' &&
row.providerSession.id === input.sessionId &&
Boolean(row.providerSession.transcriptPath)
)
if (matches.length !== 1) {
throw new Error('The owning Claude terminal did not retain a unique resume proof.')
}
return { transcriptPath: matches[0]!.providerSession!.transcriptPath! }
}
private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner {
const pty = this.ptysById.get(owner.terminal.ptyId)
if (!pty?.connected) {
@@ -102,6 +102,12 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
adapter,
journalRoot: deps.stateDirectory,
claimKeyId: deps.claimKeyId,
...(deps.resolveClaudeLaunchEnv
? {
resolveLaunchEnv: (provider) =>
provider === 'claude' ? deps.resolveClaudeLaunchEnv?.() : undefined
}
: {}),
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
onEventSinkError: ({ sessionId, error }) =>
deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }),
@@ -13,7 +13,7 @@ type Props = {
onRequest: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry'
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
) => void
}
@@ -191,7 +191,16 @@ export function StructuredAgentSessionHandoffChrome({
>
<div className="flex items-center justify-between gap-3">
<span>{status.error.message}</span>
{status.direction && status.error.recoverableOwner !== 'none' ? (
{status.direction && status.error.canRetryProof ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest(status.direction!, 'now', 'recover')}
>
{translate('components.native-chat.handoff.retryProof', 'Retry proof')}
</Button>
) : status.direction && status.error.recoverableOwner !== 'none' ? (
<Button
type="button"
variant="ghost"
@@ -249,7 +249,7 @@ export function useStructuredAgentSession(args: {
requestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action: 'start' | 'cancel-queued' | 'retry' = 'start'
action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start'
) =>
mutate<AgentSessionHandoffResult>(
'agentSession.requestHandoff',
+23
View File
@@ -37,6 +37,9 @@ export type AgentSessionAccountHome = {
path: string
}
/** Provider launch environment captured by the host when the session is created. */
export type AgentSessionLaunchEnv = Record<string, string>
export type AgentSessionOwnerRuntimeKind = 'native' | 'tui'
export type AgentSessionHandoffStage =
@@ -108,6 +111,7 @@ export type AgentSessionRecord = {
accountHome: AgentSessionAccountHome
/** Provider options acknowledged for the next turn, restored across owner replacement. */
options?: Record<string, string>
launchEnv?: AgentSessionLaunchEnv
lease: AgentSessionLease
createdAt: number
updatedAt: number
@@ -122,6 +126,8 @@ export type AgentSessionOptionsReplacement = {
const MAX_ID_LENGTH = 512
const MAX_PATH_LENGTH = 4096
const MAX_LAUNCH_ENV_ENTRIES = 256
const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/
function isBoundedString(value: unknown, max: number): value is string {
@@ -213,6 +219,22 @@ function isAgentSessionOptions(value: unknown): value is Record<string, string>
)
}
function isAgentSessionLaunchEnv(value: unknown): value is AgentSessionLaunchEnv {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const entries = Object.entries(value)
return (
entries.length <= MAX_LAUNCH_ENV_ENTRIES &&
entries.every(
([key, entry]) =>
isBoundedString(key, MAX_ID_LENGTH) &&
typeof entry === 'string' &&
entry.length <= MAX_LAUNCH_ENV_VALUE_LENGTH
)
)
}
function isAgentSessionJournalCheckpoint(value: unknown): value is AgentSessionJournalCheckpoint {
if (typeof value !== 'object' || value === null) {
return false
@@ -293,6 +315,7 @@ export function isAgentSessionRecord(value: unknown): value is AgentSessionRecor
isAgentSessionProviderHandleChain(record.providerHandleChain) &&
isAgentSessionAccountHome(record.accountHome) &&
(record.options === undefined || isAgentSessionOptions(record.options)) &&
(record.launchEnv === undefined || isAgentSessionLaunchEnv(record.launchEnv)) &&
isAgentSessionLease(record.lease) &&
record.lease.sessionId === record.sessionId &&
Number.isSafeInteger(record.createdAt) &&
+2 -1
View File
@@ -16,7 +16,7 @@ import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './a
export type AgentSessionHandoffDirection = 'to-tui' | 'to-native'
export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn'
export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry'
export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' | 'recover'
export type AgentSessionHandoffStatus = {
owner: AgentSessionOwnerRuntimeKind | 'none'
@@ -34,6 +34,7 @@ export type AgentSessionHandoffStatus = {
message: string
details?: string
recoverableOwner: AgentSessionOwnerRuntimeKind | 'none'
canRetryProof?: boolean
}
}